Merge remote-tracking branch 'origin' into debugger-tool-calls

Anthony Eid created

Change summary

.github/workflows/bump_patch_version.yml                        |   2 
.github/workflows/deploy_collab.yml                             |   6 
.github/workflows/extension_workflow_rollout.yml                | 145 
Cargo.lock                                                      |  67 
Cargo.toml                                                      |   9 
Dockerfile-collab                                               |   6 
assets/settings/default.json                                    |   4 
crates/acp_thread/src/acp_thread.rs                             |   2 
crates/acp_thread/src/connection.rs                             |   6 
crates/action_log/src/action_log.rs                             |  45 
crates/agent/src/agent.rs                                       | 462 
crates/agent/src/native_agent_server.rs                         |   8 
crates/agent/src/tests/mod.rs                                   | 103 
crates/agent/src/tools/streaming_edit_file_tool.rs              | 127 
crates/agent_servers/src/acp.rs                                 |  39 
crates/agent_servers/src/agent_servers.rs                       |  11 
crates/agent_servers/src/custom.rs                              |   1 
crates/agent_servers/src/e2e_tests.rs                           |   2 
crates/agent_ui/Cargo.toml                                      |   1 
crates/agent_ui/src/agent_connection_store.rs                   | 163 
crates/agent_ui/src/agent_panel.rs                              | 273 
crates/agent_ui/src/agent_ui.rs                                 |   8 
crates/agent_ui/src/completion_provider.rs                      | 134 
crates/agent_ui/src/connection_view.rs                          | 243 
crates/agent_ui/src/connection_view/thread_view.rs              |  46 
crates/agent_ui/src/entry_view_state.rs                         |   3 
crates/agent_ui/src/inline_assistant.rs                         |   2 
crates/agent_ui/src/mention_set.rs                              |  57 
crates/agent_ui/src/message_editor.rs                           |  49 
crates/agent_ui/src/sidebar.rs                                  | 543 
crates/agent_ui/src/thread_history.rs                           | 880 --
crates/agent_ui/src/thread_history_view.rs                      | 878 ++
crates/collab/migrations/20251208000000_test_schema.sql         |  13 
crates/copilot/src/copilot.rs                                   |   1 
crates/crashes/src/crashes.rs                                   |  28 
crates/debugger_ui/src/tests/stack_frame_list.rs                |   4 
crates/editor/src/document_colors.rs                            |   2 
crates/editor/src/document_symbols.rs                           |   2 
crates/editor/src/editor.rs                                     | 540 -
crates/editor/src/editor_tests.rs                               |  17 
crates/editor/src/element.rs                                    |   6 
crates/editor/src/folding_ranges.rs                             |   2 
crates/editor/src/inlays/inlay_hints.rs                         |   2 
crates/editor/src/linked_editing_ranges.rs                      |   2 
crates/editor/src/runnables.rs                                  | 915 +++
crates/editor/src/semantic_tokens.rs                            |   2 
crates/editor/src/split.rs                                      |   3 
crates/editor/src/tasks.rs                                      | 110 
crates/eval_cli/src/main.rs                                     |  24 
crates/feature_flags/Cargo.toml                                 |   1 
crates/feature_flags/src/feature_flags.rs                       |  61 
crates/git_graph/src/git_graph.rs                               |  23 
crates/git_ui/src/conflict_view.rs                              |  15 
crates/language/src/buffer.rs                                   |  28 
crates/languages/src/gitcommit/config.toml                      |   2 
crates/languages/src/gomod/config.toml                          |   2 
crates/languages/src/gowork/config.toml                         |   2 
crates/languages/src/python.rs                                  |  27 
crates/livekit_client/src/livekit_client/playback.rs            |  10 
crates/platform_title_bar/src/platform_title_bar.rs             |  40 
crates/project/src/agent_server_store.rs                        |  98 
crates/project/src/buffer_store.rs                              |   5 
crates/project/src/image_store.rs                               |   5 
crates/project/tests/integration/ext_agent_tests.rs             |   1 
crates/project/tests/integration/extension_agent_tests.rs       |   1 
crates/project/tests/integration/project_tests.rs               |  69 
crates/proto/proto/ai.proto                                     |   2 
crates/recent_projects/src/recent_projects.rs                   |   4 
crates/remote_server/src/remote_editing_tests.rs                |   1 
crates/settings_content/src/settings_content.rs                 |   2 
crates/sidebar/Cargo.toml                                       |  50 
crates/sidebar/LICENSE-GPL                                      |   1 
crates/sqlez/src/connection.rs                                  | 103 
crates/sqlez/src/thread_safe_connection.rs                      | 101 
crates/tasks_ui/src/tasks_ui.rs                                 |   4 
crates/title_bar/Cargo.toml                                     |   1 
crates/title_bar/src/title_bar.rs                               | 125 
crates/ui/src/components/ai/thread_item.rs                      |  45 
crates/ui/src/components/list/list_item.rs                      |  13 
crates/workspace/src/multi_workspace.rs                         | 405 -
crates/workspace/src/notifications.rs                           |  14 
crates/workspace/src/persistence.rs                             |  10 
crates/workspace/src/persistence/model.rs                       |   9 
crates/workspace/src/status_bar.rs                              |  14 
crates/workspace/src/workspace.rs                               |  29 
crates/worktree/src/worktree.rs                                 |  10 
crates/zed/Cargo.toml                                           |   3 
crates/zed/src/visual_test_runner.rs                            |  42 
crates/zed/src/zed.rs                                           |  48 
docs/theme/css/chrome.css                                       |  19 
docs/theme/index.hbs                                            |  25 
script/danger/dangerfile.ts                                     |  19 
tooling/xtask/src/tasks/workflows.rs                            |  83 
tooling/xtask/src/tasks/workflows/deploy_collab.rs              |  10 
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs | 263 
tooling/xtask/src/tasks/workflows/extensions/bump_version.rs    |   8 
tooling/xtask/src/tasks/workflows/extensions/run_tests.rs       |   9 
tooling/xtask/src/tasks/workflows/steps.rs                      |  20 
98 files changed, 4,429 insertions(+), 3,451 deletions(-)

Detailed changes

.github/workflows/bump_patch_version.yml 🔗

@@ -23,8 +23,8 @@ jobs:
       uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
       with:
         clean: false
-        token: ${{ steps.get-app-token.outputs.token }}
         ref: ${{ inputs.branch }}
+        token: ${{ steps.get-app-token.outputs.token }}
     - name: bump_patch_version::run_bump_patch_version::bump_patch_version
       run: |
         channel="$(cat crates/zed/RELEASE_CHANNEL)"

.github/workflows/deploy_collab.yml 🔗

@@ -12,6 +12,9 @@ jobs:
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
     name: Check formatting and Clippy lints
     runs-on: namespace-profile-16x32-ubuntu-2204
+    env:
+      CC: clang
+      CXX: clang++
     steps:
     - name: steps::checkout_repo
       uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -42,6 +45,9 @@ jobs:
     - style
     name: Run tests
     runs-on: namespace-profile-16x32-ubuntu-2204
+    env:
+      CC: clang
+      CXX: clang++
     steps:
     - name: steps::checkout_repo
       uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

.github/workflows/extension_workflow_rollout.yml 🔗

@@ -4,12 +4,57 @@ name: extension_workflow_rollout
 env:
   CARGO_TERM_COLOR: always
 on:
-  workflow_dispatch: {}
+  workflow_dispatch:
+    inputs:
+      filter-repos:
+        description: Comma-separated list of repository names to rollout to. Leave empty for all repos.
+        type: string
+        default: ''
+      change-description:
+        description: Description for the changes to be expected with this rollout
+        type: string
+        default: ''
 jobs:
   fetch_extension_repos:
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main'
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
+    - name: checkout_zed_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+        fetch-depth: 0
+    - id: prev-tag
+      name: extension_workflow_rollout::fetch_extension_repos::get_previous_tag_commit
+      run: |
+        PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "")
+        if [ -z "$PREV_COMMIT" ]; then
+            echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes."
+            exit 1
+        fi
+        echo "Found previous rollout at commit: $PREV_COMMIT"
+        echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
+    - id: calc-changes
+      name: extension_workflow_rollout::fetch_extension_repos::get_removed_files
+      run: |
+        for workflow_type in "ci" "shared"; do
+            if [ "$workflow_type" = "ci" ]; then
+                WORKFLOW_DIR="extensions/workflows"
+            else
+                WORKFLOW_DIR="extensions/workflows/shared"
+            fi
+
+            REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
+                awk '/^D/ { print $2 } /^R/ { print $2 }' | \
+                xargs -I{} basename {} 2>/dev/null | \
+                tr '\n' ' ' || echo "")
+            REMOVED=$(echo "$REMOVED" | xargs)
+
+            echo "Removed files for $workflow_type: $REMOVED"
+            echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT"
+        done
+      env:
+        PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }}
     - id: list-repos
       name: extension_workflow_rollout::fetch_extension_repos::get_repositories
       uses: actions/github-script@v7
@@ -21,16 +66,42 @@ jobs:
               per_page: 100,
           });
 
-          const filteredRepos = repos
+          let filteredRepos = repos
               .filter(repo => !repo.archived)
               .map(repo => repo.name);
 
+          const filterInput = `${{ inputs.filter-repos }}`.trim();
+          if (filterInput.length > 0) {
+              const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
+              filteredRepos = filteredRepos.filter(name => allowedNames.includes(name));
+              console.log(`Filter applied. Matched ${filteredRepos.length} repos from ${allowedNames.length} requested.`);
+          }
+
           console.log(`Found ${filteredRepos.length} extension repos`);
           return filteredRepos;
         result-encoding: json
+    - name: steps::cache_rust_dependencies_namespace
+      uses: namespacelabs/nscloud-cache-action@v1
+      with:
+        cache: rust
+        path: ~/.rustup
+    - name: extension_workflow_rollout::fetch_extension_repos::generate_workflow_files
+      run: |
+        cargo xtask workflows "$COMMIT_SHA"
+      env:
+        COMMIT_SHA: ${{ github.sha }}
+    - name: extension_workflow_rollout::fetch_extension_repos::upload_workflow_files
+      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+      with:
+        name: extension-workflow-files
+        path: extensions/workflows/**/*.yml
+        if-no-files-found: error
     outputs:
       repos: ${{ steps.list-repos.outputs.result }}
-    timeout-minutes: 5
+      prev_commit: ${{ steps.prev-tag.outputs.prev_commit }}
+      removed_ci: ${{ steps.calc-changes.outputs.removed_ci }}
+      removed_shared: ${{ steps.calc-changes.outputs.removed_shared }}
+    timeout-minutes: 10
   rollout_workflows_to_extension:
     needs:
     - fetch_extension_repos
@@ -53,59 +124,28 @@ jobs:
         permission-pull-requests: write
         permission-contents: write
         permission-workflows: write
-    - name: checkout_zed_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
-      with:
-        clean: false
-        fetch-depth: 0
-        path: zed
     - name: checkout_extension_repo
       uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
       with:
         clean: false
-        token: ${{ steps.generate-token.outputs.token }}
         path: extension
         repository: zed-extensions/${{ matrix.repo }}
-    - id: prev-tag
-      name: extension_workflow_rollout::rollout_workflows_to_extension::get_previous_tag_commit
-      run: |
-        PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "")
-        if [ -z "$PREV_COMMIT" ]; then
-            echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes."
-            exit 1
-        fi
-        echo "Found previous rollout at commit: $PREV_COMMIT"
-        echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
-      working-directory: zed
-    - id: calc-changes
-      name: extension_workflow_rollout::rollout_workflows_to_extension::get_removed_files
+        token: ${{ steps.generate-token.outputs.token }}
+    - name: extension_workflow_rollout::rollout_workflows_to_extension::download_workflow_files
+      uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
+      with:
+        name: extension-workflow-files
+        path: workflow-files
+    - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files
       run: |
+        mkdir -p extension/.github/workflows
+
         if [ "$MATRIX_REPO" = "workflows" ]; then
-            WORKFLOW_DIR="extensions/workflows"
+            REMOVED_FILES="$REMOVED_CI"
         else
-            WORKFLOW_DIR="extensions/workflows/shared"
+            REMOVED_FILES="$REMOVED_SHARED"
         fi
 
-        echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
-
-        # Get deleted files (status D) and renamed files (status R - old name needs removal)
-        # Using -M to detect renames, then extracting files that are gone from their original location
-        REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
-            awk '/^D/ { print $2 } /^R/ { print $2 }' | \
-            xargs -I{} basename {} 2>/dev/null | \
-            tr '\n' ' ' || echo "")
-
-        REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
-
-        echo "Files to remove: $REMOVED_FILES"
-        echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
-      env:
-        PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }}
-        MATRIX_REPO: ${{ matrix.repo }}
-      working-directory: zed
-    - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files
-      run: |
-        mkdir -p extension/.github/workflows
         cd extension/.github/workflows
 
         if [ -n "$REMOVED_FILES" ]; then
@@ -119,18 +159,18 @@ jobs:
         cd - > /dev/null
 
         if [ "$MATRIX_REPO" = "workflows" ]; then
-            cp zed/extensions/workflows/*.yml extension/.github/workflows/
+            cp workflow-files/*.yml extension/.github/workflows/
         else
-            cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
+            cp workflow-files/shared/*.yml extension/.github/workflows/
         fi
       env:
-        REMOVED_FILES: ${{ steps.calc-changes.outputs.removed_files }}
+        REMOVED_CI: ${{ needs.fetch_extension_repos.outputs.removed_ci }}
+        REMOVED_SHARED: ${{ needs.fetch_extension_repos.outputs.removed_shared }}
         MATRIX_REPO: ${{ matrix.repo }}
     - id: short-sha
       name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha
       run: |
-        echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
-      working-directory: zed
+        echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
     - id: create-pr
       name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request
       uses: peter-evans/create-pull-request@v7
@@ -140,6 +180,8 @@ jobs:
         body: |
           This PR updates the CI workflow files from the main Zed repository
           based on the commit zed-industries/zed@${{ github.sha }}
+
+          ${{ inputs.change-description }}
         commit-message: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}`
         branch: update-workflows
         committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
@@ -151,16 +193,17 @@ jobs:
     - name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge
       run: |
         if [ -n "$PR_NUMBER" ]; then
-            cd extension
             gh pr merge "$PR_NUMBER" --auto --squash
         fi
       env:
         GH_TOKEN: ${{ steps.generate-token.outputs.token }}
         PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
+      working-directory: extension
     timeout-minutes: 10
   create_rollout_tag:
     needs:
     - rollout_workflows_to_extension
+    if: inputs.filter-repos == ''
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - id: generate-token

Cargo.lock 🔗

@@ -227,9 +227,9 @@ dependencies = [
 
 [[package]]
 name = "agent-client-protocol"
-version = "0.9.4"
+version = "0.10.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475"
+checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2"
 dependencies = [
  "agent-client-protocol-schema",
  "anyhow",
@@ -244,16 +244,16 @@ dependencies = [
 
 [[package]]
 name = "agent-client-protocol-schema"
-version = "0.10.8"
+version = "0.11.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1"
+checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789"
 dependencies = [
  "anyhow",
  "derive_more",
  "schemars",
  "serde",
  "serde_json",
- "strum 0.27.2",
+ "strum 0.28.0",
 ]
 
 [[package]]
@@ -6212,7 +6212,6 @@ dependencies = [
 name = "feature_flags"
 version = "0.1.0"
 dependencies = [
- "futures 0.3.31",
  "gpui",
 ]
 
@@ -7152,7 +7151,7 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_yaml",
- "strum_macros",
+ "strum_macros 0.27.2",
 ]
 
 [[package]]
@@ -15808,33 +15807,6 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
 
-[[package]]
-name = "sidebar"
-version = "0.1.0"
-dependencies = [
- "acp_thread",
- "agent",
- "agent-client-protocol",
- "agent_ui",
- "assistant_text_thread",
- "chrono",
- "editor",
- "feature_flags",
- "fs",
- "gpui",
- "language_model",
- "menu",
- "project",
- "recent_projects",
- "serde_json",
- "settings",
- "theme",
- "ui",
- "util",
- "workspace",
- "zed_actions",
-]
-
 [[package]]
 name = "signal-hook"
 version = "0.3.18"
@@ -16572,7 +16544,16 @@ version = "0.27.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
 dependencies = [
- "strum_macros",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros 0.28.0",
 ]
 
 [[package]]
@@ -16587,6 +16568,18 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "subtle"
 version = "2.6.1"
@@ -17661,7 +17654,6 @@ dependencies = [
  "client",
  "cloud_api_types",
  "db",
- "feature_flags",
  "git_ui",
  "gpui",
  "notifications",
@@ -21764,7 +21756,7 @@ dependencies = [
 
 [[package]]
 name = "zed"
-version = "0.228.0"
+version = "0.229.0"
 dependencies = [
  "acp_thread",
  "acp_tools",
@@ -21888,7 +21880,6 @@ dependencies = [
  "settings_profile_selector",
  "settings_ui",
  "shellexpand 2.1.2",
- "sidebar",
  "smol",
  "snippet_provider",
  "snippets_ui",

Cargo.toml 🔗

@@ -173,7 +173,6 @@ members = [
     "crates/settings_profile_selector",
     "crates/settings_ui",
     "crates/shell_command_parser",
-    "crates/sidebar",
     "crates/snippet",
     "crates/snippet_provider",
     "crates/snippets_ui",
@@ -412,7 +411,6 @@ rules_library = { path = "crates/rules_library" }
 scheduler = { path = "crates/scheduler" }
 search = { path = "crates/search" }
 session = { path = "crates/session" }
-sidebar = { path = "crates/sidebar" }
 settings = { path = "crates/settings" }
 settings_content = { path = "crates/settings_content" }
 settings_json = { path = "crates/settings_json" }
@@ -475,7 +473,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
 # External crates
 #
 
-agent-client-protocol = { version = "=0.9.4", features = ["unstable"] }
+agent-client-protocol = { version = "=0.10.2", features = ["unstable"] }
 aho-corasick = "1.1"
 alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }
 any_vec = "0.14"
@@ -513,7 +511,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
 aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
 backtrace = "0.3"
 base64 = "0.22"
-bincode = "1.2.1"
 bitflags = "2.6.0"
 brotli = "8.0.2"
 bytes = "1.0"
@@ -572,7 +569,6 @@ human_bytes = "0.4.1"
 html5ever = "0.27.0"
 http = "1.1"
 http-body = "1.0"
-hyper = "0.14"
 ignore = "0.4.22"
 image = "0.25.1"
 imara-diff = "0.1.8"
@@ -690,7 +686,6 @@ serde_json_lenient = { version = "0.2", features = [
     "raw_value",
 ] }
 serde_path_to_error = "0.1.17"
-serde_repr = "0.1"
 serde_urlencoded = "0.7"
 sha2 = "0.10"
 shellexpand = "2.1.0"
@@ -721,7 +716,6 @@ time = { version = "0.3", features = [
 ] }
 tiny_http = "0.8"
 tokio = { version = "1" }
-tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
 tokio-socks = { version = "0.5.2", default-features = false, features = [
     "futures-io",
     "tokio",
@@ -907,7 +901,6 @@ refineable = { codegen-units = 1 }
 release_channel = { codegen-units = 1 }
 reqwest_client = { codegen-units = 1 }
 session = { codegen-units = 1 }
-sidebar = { codegen-units = 1 }
 snippet = { codegen-units = 1 }
 snippets_ui = { codegen-units = 1 }
 story = { codegen-units = 1 }

Dockerfile-collab 🔗

@@ -14,8 +14,12 @@ ARG GITHUB_SHA
 ENV GITHUB_SHA=$GITHUB_SHA
 
 # Also add `cmake`, since we need it to build `wasmtime`.
+# clang is needed because `webrtc-sys` uses Clang-specific compiler flags.
 RUN apt-get update; \
-    apt-get install -y --no-install-recommends cmake
+    apt-get install -y --no-install-recommends cmake clang
+
+ENV CC=clang
+ENV CXX=clang++
 
 RUN --mount=type=cache,target=./script/node_modules \
     --mount=type=cache,target=/usr/local/cargo/registry \

assets/settings/default.json 🔗

@@ -920,8 +920,8 @@
     },
     // Whether to show the addition/deletion change count next to each file in the Git panel.
     //
-    // Default: false
-    "diff_stats": false,
+    // Default: true
+    "diff_stats": true,
   },
   "message_editor": {
     // Whether to automatically replace emoji shortcodes with emoji characters.

crates/acp_thread/src/acp_thread.rs 🔗

@@ -4027,7 +4027,7 @@ mod tests {
         }
 
         fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task<gpui::Result<()>> {
-            if self.auth_methods().iter().any(|m| m.id == method) {
+            if self.auth_methods().iter().any(|m| m.id() == &method) {
                 Task::ready(Ok(()))
             } else {
                 Task::ready(Err(anyhow!("Invalid Auth Method")))

crates/acp_thread/src/connection.rs 🔗

@@ -60,7 +60,11 @@ pub trait AgentConnection {
     }
 
     /// Close an existing session. Allows the agent to free the session from memory.
-    fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
+    fn close_session(
+        self: Rc<Self>,
+        _session_id: &acp::SessionId,
+        _cx: &mut App,
+    ) -> Task<Result<()>> {
         Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported")))
     }
 

crates/action_log/src/action_log.rs 🔗

@@ -1028,6 +1028,11 @@ impl ActionLog {
             .collect()
     }
 
+    /// Returns the total number of lines added and removed across all unreviewed buffers.
+    pub fn diff_stats(&self, cx: &App) -> DiffStats {
+        DiffStats::all_files(&self.changed_buffers(cx), cx)
+    }
+
     /// Iterate over buffers changed since last read or edited by the model
     pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
         self.tracked_buffers
@@ -1044,6 +1049,46 @@ impl ActionLog {
     }
 }
 
+#[derive(Default, Debug, Clone, Copy)]
+pub struct DiffStats {
+    pub lines_added: u32,
+    pub lines_removed: u32,
+}
+
+impl DiffStats {
+    pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
+        let mut stats = DiffStats::default();
+        let diff_snapshot = diff.snapshot(cx);
+        let buffer_snapshot = buffer.snapshot();
+        let base_text = diff_snapshot.base_text();
+
+        for hunk in diff_snapshot.hunks(&buffer_snapshot) {
+            let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
+            stats.lines_added += added_rows;
+
+            let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
+            let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
+            let removed_rows = base_end.saturating_sub(base_start);
+            stats.lines_removed += removed_rows;
+        }
+
+        stats
+    }
+
+    pub fn all_files(
+        changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
+        cx: &App,
+    ) -> Self {
+        let mut total = DiffStats::default();
+        for (buffer, diff) in changed_buffers {
+            let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
+            total.lines_added += stats.lines_added;
+            total.lines_removed += stats.lines_removed;
+        }
+        total
+    }
+}
+
 #[derive(Clone)]
 pub struct ActionLogTelemetry {
     pub agent_telemetry_id: SharedString,

crates/agent/src/agent.rs 🔗

@@ -37,7 +37,8 @@ use futures::channel::{mpsc, oneshot};
 use futures::future::Shared;
 use futures::{FutureExt as _, StreamExt as _, future};
 use gpui::{
-    App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
+    App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task,
+    WeakEntity,
 };
 use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry};
 use project::{Project, ProjectItem, ProjectPath, Worktree};
@@ -65,12 +66,22 @@ pub struct RulesLoadingError {
     pub message: SharedString,
 }
 
+struct ProjectState {
+    project: Entity<Project>,
+    project_context: Entity<ProjectContext>,
+    project_context_needs_refresh: watch::Sender<()>,
+    _maintain_project_context: Task<Result<()>>,
+    context_server_registry: Entity<ContextServerRegistry>,
+    _subscriptions: Vec<Subscription>,
+}
+
 /// Holds both the internal Thread and the AcpThread for a session
 struct Session {
     /// The internal thread that processes messages
     thread: Entity<Thread>,
     /// The ACP thread that handles protocol communication
     acp_thread: Entity<acp_thread::AcpThread>,
+    project_id: EntityId,
     pending_save: Task<()>,
     _subscriptions: Vec<Subscription>,
 }
@@ -235,79 +246,47 @@ pub struct NativeAgent {
     /// Session ID -> Session mapping
     sessions: HashMap<acp::SessionId, Session>,
     thread_store: Entity<ThreadStore>,
-    /// Shared project context for all threads
-    project_context: Entity<ProjectContext>,
-    project_context_needs_refresh: watch::Sender<()>,
-    _maintain_project_context: Task<Result<()>>,
-    context_server_registry: Entity<ContextServerRegistry>,
+    /// Project-specific state keyed by project EntityId
+    projects: HashMap<EntityId, ProjectState>,
     /// Shared templates for all threads
     templates: Arc<Templates>,
     /// Cached model information
     models: LanguageModels,
-    project: Entity<Project>,
     prompt_store: Option<Entity<PromptStore>>,
     fs: Arc<dyn Fs>,
     _subscriptions: Vec<Subscription>,
 }
 
 impl NativeAgent {
-    pub async fn new(
-        project: Entity<Project>,
+    pub fn new(
         thread_store: Entity<ThreadStore>,
         templates: Arc<Templates>,
         prompt_store: Option<Entity<PromptStore>>,
         fs: Arc<dyn Fs>,
-        cx: &mut AsyncApp,
-    ) -> Result<Entity<NativeAgent>> {
+        cx: &mut App,
+    ) -> Entity<NativeAgent> {
         log::debug!("Creating new NativeAgent");
 
-        let project_context = cx
-            .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))
-            .await;
-
-        Ok(cx.new(|cx| {
-            let context_server_store = project.read(cx).context_server_store();
-            let context_server_registry =
-                cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
-
-            let mut subscriptions = vec![
-                cx.subscribe(&project, Self::handle_project_event),
-                cx.subscribe(
-                    &LanguageModelRegistry::global(cx),
-                    Self::handle_models_updated_event,
-                ),
-                cx.subscribe(
-                    &context_server_store,
-                    Self::handle_context_server_store_updated,
-                ),
-                cx.subscribe(
-                    &context_server_registry,
-                    Self::handle_context_server_registry_event,
-                ),
-            ];
+        cx.new(|cx| {
+            let mut subscriptions = vec![cx.subscribe(
+                &LanguageModelRegistry::global(cx),
+                Self::handle_models_updated_event,
+            )];
             if let Some(prompt_store) = prompt_store.as_ref() {
                 subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
             }
 
-            let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
-                watch::channel(());
             Self {
                 sessions: HashMap::default(),
                 thread_store,
-                project_context: cx.new(|_| project_context),
-                project_context_needs_refresh: project_context_needs_refresh_tx,
-                _maintain_project_context: cx.spawn(async move |this, cx| {
-                    Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
-                }),
-                context_server_registry,
+                projects: HashMap::default(),
                 templates,
                 models: LanguageModels::new(cx),
-                project,
                 prompt_store,
                 fs,
                 _subscriptions: subscriptions,
             }
-        }))
+        })
     }
 
     fn new_session(
@@ -315,10 +294,10 @@ impl NativeAgent {
         project: Entity<Project>,
         cx: &mut Context<Self>,
     ) -> Entity<AcpThread> {
-        // Create Thread
-        // Fetch default model from registry settings
+        let project_id = self.get_or_create_project_state(&project, cx);
+        let project_state = &self.projects[&project_id];
+
         let registry = LanguageModelRegistry::read_global(cx);
-        // Log available models for debugging
         let available_count = registry.available_models(cx).count();
         log::debug!("Total available models: {}", available_count);
 
@@ -328,21 +307,22 @@ impl NativeAgent {
         });
         let thread = cx.new(|cx| {
             Thread::new(
-                project.clone(),
-                self.project_context.clone(),
-                self.context_server_registry.clone(),
+                project,
+                project_state.project_context.clone(),
+                project_state.context_server_registry.clone(),
                 self.templates.clone(),
                 default_model,
                 cx,
             )
         });
 
-        self.register_session(thread, cx)
+        self.register_session(thread, project_id, cx)
     }
 
     fn register_session(
         &mut self,
         thread_handle: Entity<Thread>,
+        project_id: EntityId,
         cx: &mut Context<Self>,
     ) -> Entity<AcpThread> {
         let connection = Rc::new(NativeAgentConnection(cx.entity()));
@@ -405,12 +385,13 @@ impl NativeAgent {
             Session {
                 thread: thread_handle,
                 acp_thread: acp_thread.clone(),
+                project_id,
                 _subscriptions: subscriptions,
                 pending_save: Task::ready(()),
             },
         );
 
-        self.update_available_commands(cx);
+        self.update_available_commands_for_project(project_id, cx);
 
         acp_thread
     }
@@ -419,19 +400,102 @@ impl NativeAgent {
         &self.models
     }
 
+    fn get_or_create_project_state(
+        &mut self,
+        project: &Entity<Project>,
+        cx: &mut Context<Self>,
+    ) -> EntityId {
+        let project_id = project.entity_id();
+        if self.projects.contains_key(&project_id) {
+            return project_id;
+        }
+
+        let project_context = cx.new(|_| ProjectContext::new(vec![], vec![]));
+        self.register_project_with_initial_context(project.clone(), project_context, cx);
+        if let Some(state) = self.projects.get_mut(&project_id) {
+            state.project_context_needs_refresh.send(()).ok();
+        }
+        project_id
+    }
+
+    fn register_project_with_initial_context(
+        &mut self,
+        project: Entity<Project>,
+        project_context: Entity<ProjectContext>,
+        cx: &mut Context<Self>,
+    ) {
+        let project_id = project.entity_id();
+
+        let context_server_store = project.read(cx).context_server_store();
+        let context_server_registry =
+            cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+
+        let subscriptions = vec![
+            cx.subscribe(&project, Self::handle_project_event),
+            cx.subscribe(
+                &context_server_store,
+                Self::handle_context_server_store_updated,
+            ),
+            cx.subscribe(
+                &context_server_registry,
+                Self::handle_context_server_registry_event,
+            ),
+        ];
+
+        let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
+            watch::channel(());
+
+        self.projects.insert(
+            project_id,
+            ProjectState {
+                project,
+                project_context,
+                project_context_needs_refresh: project_context_needs_refresh_tx,
+                _maintain_project_context: cx.spawn(async move |this, cx| {
+                    Self::maintain_project_context(
+                        this,
+                        project_id,
+                        project_context_needs_refresh_rx,
+                        cx,
+                    )
+                    .await
+                }),
+                context_server_registry,
+                _subscriptions: subscriptions,
+            },
+        );
+    }
+
+    fn session_project_state(&self, session_id: &acp::SessionId) -> Option<&ProjectState> {
+        self.sessions
+            .get(session_id)
+            .and_then(|session| self.projects.get(&session.project_id))
+    }
+
     async fn maintain_project_context(
         this: WeakEntity<Self>,
+        project_id: EntityId,
         mut needs_refresh: watch::Receiver<()>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
         while needs_refresh.changed().await.is_ok() {
             let project_context = this
                 .update(cx, |this, cx| {
-                    Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx)
-                })?
+                    let state = this
+                        .projects
+                        .get(&project_id)
+                        .context("project state not found")?;
+                    anyhow::Ok(Self::build_project_context(
+                        &state.project,
+                        this.prompt_store.as_ref(),
+                        cx,
+                    ))
+                })??
                 .await;
             this.update(cx, |this, cx| {
-                this.project_context = cx.new(|_| project_context);
+                if let Some(state) = this.projects.get_mut(&project_id) {
+                    state.project_context = cx.new(|_| project_context);
+                }
             })?;
         }
 
@@ -620,13 +684,17 @@ impl NativeAgent {
 
     fn handle_project_event(
         &mut self,
-        _project: Entity<Project>,
+        project: Entity<Project>,
         event: &project::Event,
         _cx: &mut Context<Self>,
     ) {
+        let project_id = project.entity_id();
+        let Some(state) = self.projects.get_mut(&project_id) else {
+            return;
+        };
         match event {
             project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
-                self.project_context_needs_refresh.send(()).ok();
+                state.project_context_needs_refresh.send(()).ok();
             }
             project::Event::WorktreeUpdatedEntries(_, items) => {
                 if items.iter().any(|(path, _, _)| {
@@ -634,7 +702,7 @@ impl NativeAgent {
                         .iter()
                         .any(|name| path.as_ref() == RelPath::unix(name).unwrap())
                 }) {
-                    self.project_context_needs_refresh.send(()).ok();
+                    state.project_context_needs_refresh.send(()).ok();
                 }
             }
             _ => {}
@@ -647,7 +715,9 @@ impl NativeAgent {
         _event: &prompt_store::PromptsUpdatedEvent,
         _cx: &mut Context<Self>,
     ) {
-        self.project_context_needs_refresh.send(()).ok();
+        for state in self.projects.values_mut() {
+            state.project_context_needs_refresh.send(()).ok();
+        }
     }
 
     fn handle_models_updated_event(
@@ -677,30 +747,52 @@ impl NativeAgent {
 
     fn handle_context_server_store_updated(
         &mut self,
-        _store: Entity<project::context_server_store::ContextServerStore>,
+        store: Entity<project::context_server_store::ContextServerStore>,
         _event: &project::context_server_store::ServerStatusChangedEvent,
         cx: &mut Context<Self>,
     ) {
-        self.update_available_commands(cx);
+        let project_id = self.projects.iter().find_map(|(id, state)| {
+            if *state.context_server_registry.read(cx).server_store() == store {
+                Some(*id)
+            } else {
+                None
+            }
+        });
+        if let Some(project_id) = project_id {
+            self.update_available_commands_for_project(project_id, cx);
+        }
     }
 
     fn handle_context_server_registry_event(
         &mut self,
-        _registry: Entity<ContextServerRegistry>,
+        registry: Entity<ContextServerRegistry>,
         event: &ContextServerRegistryEvent,
         cx: &mut Context<Self>,
     ) {
         match event {
             ContextServerRegistryEvent::ToolsChanged => {}
             ContextServerRegistryEvent::PromptsChanged => {
-                self.update_available_commands(cx);
+                let project_id = self.projects.iter().find_map(|(id, state)| {
+                    if state.context_server_registry == registry {
+                        Some(*id)
+                    } else {
+                        None
+                    }
+                });
+                if let Some(project_id) = project_id {
+                    self.update_available_commands_for_project(project_id, cx);
+                }
             }
         }
     }
 
-    fn update_available_commands(&self, cx: &mut Context<Self>) {
-        let available_commands = self.build_available_commands(cx);
+    fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context<Self>) {
+        let available_commands =
+            Self::build_available_commands_for_project(self.projects.get(&project_id), cx);
         for session in self.sessions.values() {
+            if session.project_id != project_id {
+                continue;
+            }
             session.acp_thread.update(cx, |thread, cx| {
                 thread
                     .handle_session_update(
@@ -714,8 +806,14 @@ impl NativeAgent {
         }
     }
 
-    fn build_available_commands(&self, cx: &App) -> Vec<acp::AvailableCommand> {
-        let registry = self.context_server_registry.read(cx);
+    fn build_available_commands_for_project(
+        project_state: Option<&ProjectState>,
+        cx: &App,
+    ) -> Vec<acp::AvailableCommand> {
+        let Some(state) = project_state else {
+            return vec![];
+        };
+        let registry = state.context_server_registry.read(cx);
 
         let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default();
         for context_server_prompt in registry.prompts() {
@@ -769,8 +867,10 @@ impl NativeAgent {
     pub fn load_thread(
         &mut self,
         id: acp::SessionId,
+        project: Entity<Project>,
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Thread>>> {
+        let project_id = self.get_or_create_project_state(&project, cx);
         let database_future = ThreadsDatabase::connect(cx);
         cx.spawn(async move |this, cx| {
             let database = database_future.await.map_err(|err| anyhow!(err))?;
@@ -780,41 +880,48 @@ impl NativeAgent {
                 .with_context(|| format!("no thread found with ID: {id:?}"))?;
 
             this.update(cx, |this, cx| {
+                let project_state = this
+                    .projects
+                    .get(&project_id)
+                    .context("project state not found")?;
                 let summarization_model = LanguageModelRegistry::read_global(cx)
                     .thread_summary_model()
                     .map(|c| c.model);
 
-                cx.new(|cx| {
+                Ok(cx.new(|cx| {
                     let mut thread = Thread::from_db(
                         id.clone(),
                         db_thread,
-                        this.project.clone(),
-                        this.project_context.clone(),
-                        this.context_server_registry.clone(),
+                        project_state.project.clone(),
+                        project_state.project_context.clone(),
+                        project_state.context_server_registry.clone(),
                         this.templates.clone(),
                         cx,
                     );
                     thread.set_summarization_model(summarization_model, cx);
                     thread
-                })
-            })
+                }))
+            })?
         })
     }
 
     pub fn open_thread(
         &mut self,
         id: acp::SessionId,
+        project: Entity<Project>,
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<AcpThread>>> {
         if let Some(session) = self.sessions.get(&id) {
             return Task::ready(Ok(session.acp_thread.clone()));
         }
 
-        let task = self.load_thread(id, cx);
+        let project_id = self.get_or_create_project_state(&project, cx);
+        let task = self.load_thread(id, project, cx);
         cx.spawn(async move |this, cx| {
             let thread = task.await?;
-            let acp_thread =
-                this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?;
+            let acp_thread = this.update(cx, |this, cx| {
+                this.register_session(thread.clone(), project_id, cx)
+            })?;
             let events = thread.update(cx, |thread, cx| thread.replay(cx));
             cx.update(|cx| {
                 NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx)
@@ -827,9 +934,10 @@ impl NativeAgent {
     pub fn thread_summary(
         &mut self,
         id: acp::SessionId,
+        project: Entity<Project>,
         cx: &mut Context<Self>,
     ) -> Task<Result<SharedString>> {
-        let thread = self.open_thread(id.clone(), cx);
+        let thread = self.open_thread(id.clone(), project, cx);
         cx.spawn(async move |this, cx| {
             let acp_thread = thread.await?;
             let result = this
@@ -857,8 +965,13 @@ impl NativeAgent {
             return;
         };
 
+        let project_id = session.project_id;
+        let Some(state) = self.projects.get(&project_id) else {
+            return;
+        };
+
         let folder_paths = PathList::new(
-            &self
+            &state
                 .project
                 .read(cx)
                 .visible_worktrees(cx)
@@ -889,15 +1002,22 @@ impl NativeAgent {
     fn send_mcp_prompt(
         &self,
         message_id: UserMessageId,
-        session_id: agent_client_protocol::SessionId,
+        session_id: acp::SessionId,
         prompt_name: String,
         server_id: ContextServerId,
         arguments: HashMap<String, String>,
         original_content: Vec<acp::ContentBlock>,
         cx: &mut Context<Self>,
     ) -> Task<Result<acp::PromptResponse>> {
-        let server_store = self.context_server_registry.read(cx).server_store().clone();
-        let path_style = self.project.read(cx).path_style(cx);
+        let Some(state) = self.session_project_state(&session_id) else {
+            return Task::ready(Err(anyhow!("Project state not found for session")));
+        };
+        let server_store = state
+            .context_server_registry
+            .read(cx)
+            .server_store()
+            .clone();
+        let path_style = state.project.read(cx).path_style(cx);
 
         cx.spawn(async move |this, cx| {
             let prompt =
@@ -996,8 +1116,14 @@ impl NativeAgentConnection {
             .map(|session| session.thread.clone())
     }
 
-    pub fn load_thread(&self, id: acp::SessionId, cx: &mut App) -> Task<Result<Entity<Thread>>> {
-        self.0.update(cx, |this, cx| this.load_thread(id, cx))
+    pub fn load_thread(
+        &self,
+        id: acp::SessionId,
+        project: Entity<Project>,
+        cx: &mut App,
+    ) -> Task<Result<Entity<Thread>>> {
+        self.0
+            .update(cx, |this, cx| this.load_thread(id, project, cx))
     }
 
     fn run_turn(
@@ -1279,22 +1405,34 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
     fn load_session(
         self: Rc<Self>,
         session_id: acp::SessionId,
-        _project: Entity<Project>,
+        project: Entity<Project>,
         _cwd: &Path,
         _title: Option<SharedString>,
         cx: &mut App,
     ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
         self.0
-            .update(cx, |agent, cx| agent.open_thread(session_id, cx))
+            .update(cx, |agent, cx| agent.open_thread(session_id, project, cx))
     }
 
     fn supports_close_session(&self) -> bool {
         true
     }
 
-    fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task<Result<()>> {
+    fn close_session(
+        self: Rc<Self>,
+        session_id: &acp::SessionId,
+        cx: &mut App,
+    ) -> Task<Result<()>> {
         self.0.update(cx, |agent, _cx| {
+            let project_id = agent.sessions.get(session_id).map(|s| s.project_id);
             agent.sessions.remove(session_id);
+
+            if let Some(project_id) = project_id {
+                let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id);
+                if !has_remaining {
+                    agent.projects.remove(&project_id);
+                }
+            }
         });
         Task::ready(Ok(()))
     }
@@ -1325,8 +1463,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
         log::info!("Received prompt request for session: {}", session_id);
         log::debug!("Prompt blocks count: {}", params.prompt.len());
 
+        let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else {
+            return Task::ready(Err(anyhow::anyhow!("Session not found")));
+        };
+
         if let Some(parsed_command) = Command::parse(&params.prompt) {
-            let registry = self.0.read(cx).context_server_registry.read(cx);
+            let registry = project_state.context_server_registry.read(cx);
 
             let explicit_server_id = parsed_command
                 .explicit_server_id
@@ -1362,10 +1504,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
                         cx,
                     )
                 });
-            };
+            }
         };
 
-        let path_style = self.0.read(cx).project.read(cx).path_style(cx);
+        let path_style = project_state.project.read(cx).path_style(cx);
 
         self.run_turn(session_id, cx, move |thread, cx| {
             let content: Vec<UserMessageContent> = params
@@ -1406,7 +1548,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
 
     fn truncate(
         &self,
-        session_id: &agent_client_protocol::SessionId,
+        session_id: &acp::SessionId,
         cx: &App,
     ) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
         self.0.read_with(cx, |agent, _cx| {
@@ -1611,6 +1753,7 @@ impl NativeThreadEnvironment {
         };
         let parent_thread = parent_thread_entity.read(cx);
         let current_depth = parent_thread.depth();
+        let parent_session_id = parent_thread.id().clone();
 
         if current_depth >= MAX_SUBAGENT_DEPTH {
             return Err(anyhow!(
@@ -1627,9 +1770,16 @@ impl NativeThreadEnvironment {
 
         let session_id = subagent_thread.read(cx).id().clone();
 
-        let acp_thread = self.agent.update(cx, |agent, cx| {
-            agent.register_session(subagent_thread.clone(), cx)
-        })?;
+        let acp_thread = self
+            .agent
+            .update(cx, |agent, cx| -> Result<Entity<AcpThread>> {
+                let project_id = agent
+                    .sessions
+                    .get(&parent_session_id)
+                    .map(|s| s.project_id)
+                    .context("parent session not found")?;
+                Ok(agent.register_session(subagent_thread.clone(), project_id, cx))
+            })??;
 
         let depth = current_depth + 1;
 
@@ -1955,18 +2105,21 @@ mod internal_tests {
         .await;
         let project = Project::test(fs.clone(), [], cx).await;
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let agent = NativeAgent::new(
-            project.clone(),
-            thread_store,
-            Templates::new(),
-            None,
-            fs.clone(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
+        let agent =
+            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
+
+        // Creating a session registers the project and triggers context building.
+        let connection = NativeAgentConnection(agent.clone());
+        let _acp_thread = cx
+            .update(|cx| Rc::new(connection).new_session(project.clone(), Path::new("/"), cx))
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
         agent.read_with(cx, |agent, cx| {
-            assert_eq!(agent.project_context.read(cx).worktrees, vec![])
+            let project_id = project.entity_id();
+            let state = agent.projects.get(&project_id).unwrap();
+            assert_eq!(state.project_context.read(cx).worktrees, vec![])
         });
 
         let worktree = project
@@ -1975,8 +2128,10 @@ mod internal_tests {
             .unwrap();
         cx.run_until_parked();
         agent.read_with(cx, |agent, cx| {
+            let project_id = project.entity_id();
+            let state = agent.projects.get(&project_id).unwrap();
             assert_eq!(
-                agent.project_context.read(cx).worktrees,
+                state.project_context.read(cx).worktrees,
                 vec![WorktreeContext {
                     root_name: "a".into(),
                     abs_path: Path::new("/a").into(),
@@ -1989,12 +2144,14 @@ mod internal_tests {
         fs.insert_file("/a/.rules", Vec::new()).await;
         cx.run_until_parked();
         agent.read_with(cx, |agent, cx| {
+            let project_id = project.entity_id();
+            let state = agent.projects.get(&project_id).unwrap();
             let rules_entry = worktree
                 .read(cx)
                 .entry_for_path(rel_path(".rules"))
                 .unwrap();
             assert_eq!(
-                agent.project_context.read(cx).worktrees,
+                state.project_context.read(cx).worktrees,
                 vec![WorktreeContext {
                     root_name: "a".into(),
                     abs_path: Path::new("/a").into(),
@@ -2015,18 +2172,10 @@ mod internal_tests {
         fs.insert_tree("/", json!({ "a": {}  })).await;
         let project = Project::test(fs.clone(), [], cx).await;
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let connection = NativeAgentConnection(
-            NativeAgent::new(
-                project.clone(),
-                thread_store,
-                Templates::new(),
-                None,
-                fs.clone(),
-                &mut cx.to_async(),
-            )
-            .await
-            .unwrap(),
-        );
+        let connection =
+            NativeAgentConnection(cx.update(|cx| {
+                NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)
+            }));
 
         // Create a thread/session
         let acp_thread = cx
@@ -2095,16 +2244,8 @@ mod internal_tests {
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
 
         // Create the agent and connection
-        let agent = NativeAgent::new(
-            project.clone(),
-            thread_store,
-            Templates::new(),
-            None,
-            fs.clone(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
+        let agent =
+            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
         let connection = NativeAgentConnection(agent.clone());
 
         // Create a thread/session
@@ -2196,16 +2337,8 @@ mod internal_tests {
         let project = Project::test(fs.clone(), [], cx).await;
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let agent = NativeAgent::new(
-            project.clone(),
-            thread_store,
-            Templates::new(),
-            None,
-            fs.clone(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
+        let agent =
+            cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
         let connection = NativeAgentConnection(agent.clone());
 
         let acp_thread = cx
@@ -2288,16 +2421,9 @@ mod internal_tests {
         fs.insert_tree("/", json!({ "a": {} })).await;
         let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let agent = NativeAgent::new(
-            project.clone(),
-            thread_store.clone(),
-            Templates::new(),
-            None,
-            fs.clone(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
+        let agent = cx.update(|cx| {
+            NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+        });
         let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
         // Register a thinking model.
@@ -2371,7 +2497,9 @@ mod internal_tests {
 
         // Reload the thread and verify thinking_enabled is still true.
         let reloaded_acp_thread = agent
-            .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+            .update(cx, |agent, cx| {
+                agent.open_thread(session_id.clone(), project.clone(), cx)
+            })
             .await
             .unwrap();
         let reloaded_thread = agent.read_with(cx, |agent, _| {
@@ -2394,16 +2522,9 @@ mod internal_tests {
         fs.insert_tree("/", json!({ "a": {} })).await;
         let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let agent = NativeAgent::new(
-            project.clone(),
-            thread_store.clone(),
-            Templates::new(),
-            None,
-            fs.clone(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
+        let agent = cx.update(|cx| {
+            NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+        });
         let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
         // Register a model where id() != name(), like real Anthropic models
@@ -2478,7 +2599,9 @@ mod internal_tests {
 
         // Reload the thread and verify the model was preserved.
         let reloaded_acp_thread = agent
-            .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+            .update(cx, |agent, cx| {
+                agent.open_thread(session_id.clone(), project.clone(), cx)
+            })
             .await
             .unwrap();
         let reloaded_thread = agent.read_with(cx, |agent, _| {
@@ -2513,16 +2636,9 @@ mod internal_tests {
         .await;
         let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let agent = NativeAgent::new(
-            project.clone(),
-            thread_store.clone(),
-            Templates::new(),
-            None,
-            fs.clone(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
+        let agent = cx.update(|cx| {
+            NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+        });
         let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
         let acp_thread = cx
@@ -2642,7 +2758,9 @@ mod internal_tests {
             )]
         );
         let acp_thread = agent
-            .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+            .update(cx, |agent, cx| {
+                agent.open_thread(session_id.clone(), project.clone(), cx)
+            })
             .await
             .unwrap();
         acp_thread.read_with(cx, |thread, cx| {

crates/agent/src/native_agent_server.rs 🔗

@@ -35,11 +35,10 @@ impl AgentServer for NativeAgentServer {
 
     fn connect(
         &self,
-        delegate: AgentServerDelegate,
+        _delegate: AgentServerDelegate,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
         log::debug!("NativeAgentServer::connect");
-        let project = delegate.project().clone();
         let fs = self.fs.clone();
         let thread_store = self.thread_store.clone();
         let prompt_store = PromptStore::global(cx);
@@ -49,9 +48,8 @@ impl AgentServer for NativeAgentServer {
             let prompt_store = prompt_store.await?;
 
             log::debug!("Creating native agent entity");
-            let agent =
-                NativeAgent::new(project, thread_store, templates, Some(prompt_store), fs, cx)
-                    .await?;
+            let agent = cx
+                .update(|cx| NativeAgent::new(thread_store, templates, Some(prompt_store), fs, cx));
 
             // Create the connection wrapper
             let connection = NativeAgentConnection(agent);

crates/agent/src/tests/mod.rs 🔗

@@ -3181,16 +3181,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
 
     // Create agent and connection
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store,
-        templates.clone(),
-        None,
-        fake_fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx
+        .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx));
     let connection = NativeAgentConnection(agent.clone());
 
     // Create a thread using new_thread
@@ -4388,16 +4380,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -4530,16 +4515,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -4685,16 +4663,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -4822,16 +4793,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -5201,16 +5165,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -5334,16 +5291,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -5515,16 +5465,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx

crates/agent/src/tools/streaming_edit_file_tool.rs 🔗

@@ -118,7 +118,7 @@ pub struct Edit {
     pub new_text: String,
 }
 
-#[derive(Default, Debug, Deserialize)]
+#[derive(Clone, Default, Debug, Deserialize)]
 struct StreamingEditFileToolPartialInput {
     #[serde(default)]
     display_description: Option<String>,
@@ -132,7 +132,7 @@ struct StreamingEditFileToolPartialInput {
     edits: Option<Vec<PartialEdit>>,
 }
 
-#[derive(Default, Debug, Deserialize)]
+#[derive(Clone, Default, Debug, Deserialize)]
 pub struct PartialEdit {
     #[serde(default)]
     pub old_text: Option<String>,
@@ -314,12 +314,19 @@ impl AgentTool for StreamingEditFileTool {
     ) -> Task<Result<Self::Output, Self::Output>> {
         cx.spawn(async move |cx: &mut AsyncApp| {
             let mut state: Option<EditSession> = None;
+            let mut last_partial: Option<StreamingEditFileToolPartialInput> = None;
             loop {
                 futures::select! {
                     partial = input.recv_partial().fuse() => {
                         let Some(partial_value) = partial else { break };
                         if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) {
+                            let path_complete = parsed.path.is_some()
+                                && parsed.path.as_ref() == last_partial.as_ref().and_then(|p| p.path.as_ref());
+
+                            last_partial = Some(parsed.clone());
+
                             if state.is_none()
+                                && path_complete
                                 && let StreamingEditFileToolPartialInput {
                                     path: Some(path),
                                     display_description: Some(display_description),
@@ -1907,6 +1914,13 @@ mod tests {
         let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
 
         // Setup + single edit that stays in-progress (no second edit to prove completion)
+        sender.send_partial(json!({
+            "display_description": "Single edit",
+            "path": "root/file.txt",
+            "mode": "edit",
+        }));
+        cx.run_until_parked();
+
         sender.send_partial(json!({
             "display_description": "Single edit",
             "path": "root/file.txt",
@@ -3475,6 +3489,12 @@ mod tests {
         let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
 
         // Transition to BufferResolved
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "path": "root/file.txt",
+        }));
+        cx.run_until_parked();
+
         sender.send_partial(json!({
             "display_description": "Overwrite file",
             "path": "root/file.txt",
@@ -3550,8 +3570,9 @@ mod tests {
         // Verify buffer still has old content (no content partial yet)
         let buffer = project.update(cx, |project, cx| {
             let path = project.find_project_path("root/file.txt", cx).unwrap();
-            project.get_open_buffer(&path, cx).unwrap()
+            project.open_buffer(path, cx)
         });
+        let buffer = buffer.await.unwrap();
         assert_eq!(
             buffer.read_with(cx, |b, _| b.text()),
             "old line 1\nold line 2\nold line 3\n"
@@ -3735,6 +3756,106 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode(
+        cx: &mut TestAppContext,
+    ) {
+        let (tool, _project, _action_log, _fs, _thread) =
+            setup_test(cx, json!({"file.txt": "old_content"})).await;
+        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
+        let (event_stream, _receiver) = ToolCallEventStream::test();
+        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "write"
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "write",
+            "content": "new_content"
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "write",
+            "content": "new_content",
+            "path": "root"
+        }));
+        cx.run_until_parked();
+
+        // Send final.
+        sender.send_final(json!({
+            "display_description": "Overwrite file",
+            "mode": "write",
+            "content": "new_content",
+            "path": "root/file.txt"
+        }));
+
+        let result = task.await;
+        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
+            panic!("expected success");
+        };
+        assert_eq!(new_text, "new_content");
+    }
+
+    #[gpui::test]
+    async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode(
+        cx: &mut TestAppContext,
+    ) {
+        let (tool, _project, _action_log, _fs, _thread) =
+            setup_test(cx, json!({"file.txt": "old_content"})).await;
+        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
+        let (event_stream, _receiver) = ToolCallEventStream::test();
+        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit"
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content"}]
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content", "new_text": "new_content"}]
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
+            "path": "root"
+        }));
+        cx.run_until_parked();
+
+        // Send final.
+        sender.send_final(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
+            "path": "root/file.txt"
+        }));
+        cx.run_until_parked();
+
+        let result = task.await;
+        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
+            panic!("expected success");
+        };
+        assert_eq!(new_text, "new_content");
+    }
+
     async fn setup_test_with_fs(
         cx: &mut TestAppContext,
         fs: Arc<project::FakeFs>,

crates/agent_servers/src/acp.rs 🔗

@@ -279,7 +279,7 @@ impl AcpConnection {
                 acp::InitializeRequest::new(acp::ProtocolVersion::V1)
                     .client_capabilities(
                         acp::ClientCapabilities::new()
-                            .fs(acp::FileSystemCapability::new()
+                            .fs(acp::FileSystemCapabilities::new()
                                 .read_text_file(true)
                                 .write_text_file(true))
                             .terminal(true)
@@ -331,11 +331,11 @@ impl AcpConnection {
                 "env": command.env.clone().unwrap_or_default(),
             });
             let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]);
-            vec![
-                acp::AuthMethod::new("spawn-gemini-cli", "Login")
+            vec![acp::AuthMethod::Agent(
+                acp::AuthMethodAgent::new("spawn-gemini-cli", "Login")
                     .description("Login with your Google or Vertex AI account")
                     .meta(meta),
-            ]
+            )]
         } else {
             response.auth_methods
         };
@@ -744,6 +744,31 @@ impl AgentConnection for AcpConnection {
         })
     }
 
+    fn supports_close_session(&self) -> bool {
+        self.agent_capabilities.session_capabilities.close.is_some()
+    }
+
+    fn close_session(
+        self: Rc<Self>,
+        session_id: &acp::SessionId,
+        cx: &mut App,
+    ) -> Task<Result<()>> {
+        if !self.agent_capabilities.session_capabilities.close.is_none() {
+            return Task::ready(Err(anyhow!(LoadError::Other(
+                "Closing sessions is not supported by this agent.".into()
+            ))));
+        }
+
+        let conn = self.connection.clone();
+        let session_id = session_id.clone();
+        cx.foreground_executor().spawn(async move {
+            conn.close_session(acp::CloseSessionRequest::new(session_id.clone()))
+                .await?;
+            self.sessions.borrow_mut().remove(&session_id);
+            Ok(())
+        })
+    }
+
     fn auth_methods(&self) -> &[acp::AuthMethod] {
         &self.auth_methods
     }
@@ -1373,10 +1398,10 @@ impl acp::Client for ClientDelegate {
         Ok(acp::CreateTerminalResponse::new(terminal_id))
     }
 
-    async fn kill_terminal_command(
+    async fn kill_terminal(
         &self,
-        args: acp::KillTerminalCommandRequest,
-    ) -> Result<acp::KillTerminalCommandResponse, acp::Error> {
+        args: acp::KillTerminalRequest,
+    ) -> Result<acp::KillTerminalResponse, acp::Error> {
         self.session_thread(&args.session_id)?
             .update(&mut self.cx.clone(), |thread, cx| {
                 thread.kill_terminal(args.terminal_id, cx)

crates/agent_servers/src/agent_servers.rs 🔗

@@ -14,7 +14,6 @@ use project::agent_server_store::AgentServerStore;
 use acp_thread::AgentConnection;
 use anyhow::Result;
 use gpui::{App, AppContext, Entity, SharedString, Task};
-use project::Project;
 use settings::SettingsStore;
 use std::{any::Any, rc::Rc, sync::Arc};
 
@@ -22,29 +21,19 @@ pub use acp::AcpConnection;
 
 pub struct AgentServerDelegate {
     store: Entity<AgentServerStore>,
-    project: Entity<Project>,
-    status_tx: Option<watch::Sender<SharedString>>,
     new_version_available: Option<watch::Sender<Option<String>>>,
 }
 
 impl AgentServerDelegate {
     pub fn new(
         store: Entity<AgentServerStore>,
-        project: Entity<Project>,
-        status_tx: Option<watch::Sender<SharedString>>,
         new_version_tx: Option<watch::Sender<Option<String>>>,
     ) -> Self {
         Self {
             store,
-            project,
-            status_tx,
             new_version_available: new_version_tx,
         }
     }
-
-    pub fn project(&self) -> &Entity<Project> {
-        &self.project
-    }
 }
 
 pub trait AgentServer: Send {

crates/agent_servers/src/custom.rs 🔗

@@ -364,7 +364,6 @@ impl AgentServer for CustomAgentServer {
                         })?;
                     anyhow::Ok(agent.get_command(
                         extra_env,
-                        delegate.status_tx,
                         delegate.new_version_available,
                         &mut cx.to_async(),
                     ))

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -431,7 +431,7 @@ pub async fn new_test_thread(
     cx: &mut TestAppContext,
 ) -> Entity<AcpThread> {
     let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
-    let delegate = AgentServerDelegate::new(store, project.clone(), None, None);
+    let delegate = AgentServerDelegate::new(store, None);
 
     let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap();
 

crates/agent_ui/Cargo.toml 🔗

@@ -132,7 +132,6 @@ languages = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, "features" = ["test-support"] }
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
-
 semver.workspace = true
 reqwest_client.workspace = true
 

crates/agent_ui/src/agent_connection_store.rs 🔗

@@ -0,0 +1,163 @@
+use std::rc::Rc;
+
+use acp_thread::{AgentConnection, LoadError};
+use agent_servers::{AgentServer, AgentServerDelegate};
+use anyhow::Result;
+use collections::HashMap;
+use futures::{FutureExt, future::Shared};
+use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+use project::{AgentServerStore, AgentServersUpdated, Project};
+use watch::Receiver;
+
+use crate::ExternalAgent;
+use project::ExternalAgentServerName;
+
+pub enum ConnectionEntry {
+    Connecting {
+        connect_task: Shared<Task<Result<Rc<dyn AgentConnection>, LoadError>>>,
+    },
+    Connected {
+        connection: Rc<dyn AgentConnection>,
+    },
+    Error {
+        error: LoadError,
+    },
+}
+
+impl ConnectionEntry {
+    pub fn wait_for_connection(&self) -> Shared<Task<Result<Rc<dyn AgentConnection>, LoadError>>> {
+        match self {
+            ConnectionEntry::Connecting { connect_task } => connect_task.clone(),
+            ConnectionEntry::Connected { connection } => {
+                Task::ready(Ok(connection.clone())).shared()
+            }
+            ConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(),
+        }
+    }
+}
+
+pub enum ConnectionEntryEvent {
+    NewVersionAvailable(SharedString),
+}
+
+impl EventEmitter<ConnectionEntryEvent> for ConnectionEntry {}
+
+pub struct AgentConnectionStore {
+    project: Entity<Project>,
+    entries: HashMap<ExternalAgent, Entity<ConnectionEntry>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl AgentConnectionStore {
+    pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
+        let agent_server_store = project.read(cx).agent_server_store().clone();
+        let subscription = cx.subscribe(&agent_server_store, Self::handle_agent_servers_updated);
+        Self {
+            project,
+            entries: HashMap::default(),
+            _subscriptions: vec![subscription],
+        }
+    }
+
+    pub fn request_connection(
+        &mut self,
+        key: ExternalAgent,
+        server: Rc<dyn AgentServer>,
+        cx: &mut Context<Self>,
+    ) -> Entity<ConnectionEntry> {
+        self.entries.get(&key).cloned().unwrap_or_else(|| {
+            let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx);
+            let connect_task = connect_task.shared();
+
+            let entry = cx.new(|_cx| ConnectionEntry::Connecting {
+                connect_task: connect_task.clone(),
+            });
+
+            self.entries.insert(key.clone(), entry.clone());
+
+            cx.spawn({
+                let key = key.clone();
+                let entry = entry.clone();
+                async move |this, cx| match connect_task.await {
+                    Ok(connection) => {
+                        entry.update(cx, |entry, cx| {
+                            if let ConnectionEntry::Connecting { .. } = entry {
+                                *entry = ConnectionEntry::Connected { connection };
+                                cx.notify();
+                            }
+                        });
+                    }
+                    Err(error) => {
+                        entry.update(cx, |entry, cx| {
+                            if let ConnectionEntry::Connecting { .. } = entry {
+                                *entry = ConnectionEntry::Error { error };
+                                cx.notify();
+                            }
+                        });
+                        this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
+                    }
+                }
+            })
+            .detach();
+
+            cx.spawn({
+                let entry = entry.clone();
+                async move |this, cx| {
+                    while let Ok(version) = new_version_rx.recv().await {
+                        if let Some(version) = version {
+                            entry.update(cx, |_entry, cx| {
+                                cx.emit(ConnectionEntryEvent::NewVersionAvailable(
+                                    version.clone().into(),
+                                ));
+                            });
+                            this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
+                        }
+                    }
+                }
+            })
+            .detach();
+
+            entry
+        })
+    }
+
+    fn handle_agent_servers_updated(
+        &mut self,
+        store: Entity<AgentServerStore>,
+        _: &AgentServersUpdated,
+        cx: &mut Context<Self>,
+    ) {
+        let store = store.read(cx);
+        self.entries.retain(|key, _| match key {
+            ExternalAgent::NativeAgent => true,
+            ExternalAgent::Custom { name } => store
+                .external_agents
+                .contains_key(&ExternalAgentServerName(name.clone())),
+        });
+        cx.notify();
+    }
+
+    fn start_connection(
+        &self,
+        server: Rc<dyn AgentServer>,
+        cx: &mut Context<Self>,
+    ) -> (
+        Receiver<Option<String>>,
+        Task<Result<Rc<dyn AgentConnection>, LoadError>>,
+    ) {
+        let (new_version_tx, new_version_rx) = watch::channel::<Option<String>>(None);
+
+        let agent_server_store = self.project.read(cx).agent_server_store().clone();
+        let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx));
+
+        let connect_task = server.connect(delegate, cx);
+        let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await {
+            Ok(connection) => Ok(connection),
+            Err(err) => match err.downcast::<LoadError>() {
+                Ok(load_error) => Err(load_error),
+                Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))),
+            },
+        });
+        (new_version_rx, connect_task)
+    }
+}

crates/agent_ui/src/agent_panel.rs 🔗

@@ -30,6 +30,7 @@ use zed_actions::agent::{
 };
 
 use crate::ManageProfiles;
+use crate::agent_connection_store::AgentConnectionStore;
 use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
 use crate::{
     AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow,
@@ -47,7 +48,7 @@ use crate::{
     NewNativeAgentThreadFromSummary,
 };
 use crate::{
-    ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
+    ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent,
     text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
 };
 use agent_settings::AgentSettings;
@@ -64,9 +65,10 @@ use extension_host::ExtensionStore;
 use fs::Fs;
 use git::repository::validate_worktree_directory;
 use gpui::{
-    Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
-    DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
-    Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
+    Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem,
+    Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle,
+    Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity,
+    deferred, prelude::*, pulsating_between,
 };
 use language::LanguageRegistry;
 use language_model::{ConfigurationError, LanguageModelRegistry};
@@ -78,15 +80,17 @@ use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, update_settings_file};
 use theme::ThemeSettings;
 use ui::{
-    Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding,
-    PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
+    Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator,
+    KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
     utils::WithRemSize,
 };
 use util::ResultExt as _;
 use workspace::{
-    CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
-    WorkspaceId,
+    CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar,
+    MultiWorkspace, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom,
+    ToolbarItemView, Workspace, WorkspaceId,
     dock::{DockPosition, Panel, PanelEvent},
+    multi_workspace_enabled,
 };
 use zed_actions::{
     DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
@@ -98,6 +102,55 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
 const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
 const DEFAULT_THREAD_TITLE: &str = "New Thread";
 
+#[derive(Default)]
+struct SidebarsByWindow(
+    collections::HashMap<gpui::WindowId, gpui::WeakEntity<crate::sidebar::Sidebar>>,
+);
+
+impl gpui::Global for SidebarsByWindow {}
+
+pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool {
+    if !multi_workspace_enabled(cx) {
+        return false;
+    }
+    let window_id = window.window_handle().window_id();
+    cx.try_global::<SidebarsByWindow>()
+        .and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade())
+        .is_some_and(|sidebar| sidebar.read(cx).is_open())
+}
+
+fn find_or_create_sidebar_for_window(
+    window: &mut Window,
+    cx: &mut App,
+) -> Option<Entity<crate::sidebar::Sidebar>> {
+    let window_id = window.window_handle().window_id();
+    let multi_workspace = window.root::<MultiWorkspace>().flatten()?;
+
+    if !cx.has_global::<SidebarsByWindow>() {
+        cx.set_global(SidebarsByWindow::default());
+    }
+
+    cx.global_mut::<SidebarsByWindow>()
+        .0
+        .retain(|_, weak| weak.upgrade().is_some());
+
+    let existing = cx
+        .global::<SidebarsByWindow>()
+        .0
+        .get(&window_id)
+        .and_then(|weak| weak.upgrade());
+
+    if let Some(sidebar) = existing {
+        return Some(sidebar);
+    }
+
+    let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx));
+    cx.global_mut::<SidebarsByWindow>()
+        .0
+        .insert(window_id, sidebar.downgrade());
+    Some(sidebar)
+}
+
 fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
     let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
     let key = i64::from(workspace_id).to_string();
@@ -423,6 +476,30 @@ pub fn init(cx: &mut App) {
                             panel.set_start_thread_in(action, cx);
                         });
                     }
+                })
+                .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| {
+                    if !multi_workspace_enabled(cx) {
+                        return;
+                    }
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        if let Some(sidebar) = panel.read(cx).sidebar.clone() {
+                            sidebar.update(cx, |sidebar, cx| {
+                                sidebar.toggle(window, cx);
+                            });
+                        }
+                    }
+                })
+                .register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| {
+                    if !multi_workspace_enabled(cx) {
+                        return;
+                    }
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        if let Some(sidebar) = panel.read(cx).sidebar.clone() {
+                            sidebar.update(cx, |sidebar, cx| {
+                                sidebar.focus_or_unfocus(workspace, window, cx);
+                            });
+                        }
+                    }
                 });
         },
     )
@@ -786,10 +863,12 @@ pub struct AgentPanel {
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
     acp_history: Entity<ThreadHistory>,
+    acp_history_view: Entity<ThreadHistoryView>,
     text_thread_history: Entity<TextThreadHistory>,
     thread_store: Entity<ThreadStore>,
     text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
     prompt_store: Option<Entity<PromptStore>>,
+    connection_store: Entity<AgentConnectionStore>,
     context_server_registry: Entity<ContextServerRegistry>,
     configuration: Option<Entity<AgentConfiguration>>,
     configuration_subscription: Option<Subscription>,
@@ -818,6 +897,7 @@ pub struct AgentPanel {
     last_configuration_error_telemetry: Option<String>,
     on_boarding_upsell_dismissed: AtomicBool,
     _active_view_observation: Option<Subscription>,
+    pub(crate) sidebar: Option<Entity<crate::sidebar::Sidebar>>,
 }
 
 impl AgentPanel {
@@ -989,19 +1069,19 @@ impl AgentPanel {
         let client = workspace.client().clone();
         let workspace_id = workspace.database_id();
         let workspace = workspace.weak_handle();
-
         let context_server_registry =
             cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 
         let thread_store = ThreadStore::global(cx);
-        let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx));
+        let acp_history = cx.new(|cx| ThreadHistory::new(None, cx));
+        let acp_history_view = cx.new(|cx| ThreadHistoryView::new(acp_history.clone(), window, cx));
         let text_thread_history =
             cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
         cx.subscribe_in(
-            &acp_history,
+            &acp_history_view,
             window,
             |this, _, event, window, cx| match event {
-                ThreadHistoryEvent::Open(thread) => {
+                ThreadHistoryViewEvent::Open(thread) => {
                     this.load_agent_thread(
                         thread.session_id.clone(),
                         thread.cwd.clone(),
@@ -1116,6 +1196,7 @@ impl AgentPanel {
             language_registry,
             text_thread_store,
             prompt_store,
+            connection_store: cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)),
             configuration: None,
             configuration_subscription: None,
             focus_handle: cx.focus_handle(),
@@ -1134,6 +1215,7 @@ impl AgentPanel {
             pending_serialization: None,
             onboarding,
             acp_history,
+            acp_history_view,
             text_thread_history,
             thread_store,
             selected_agent: AgentType::default(),
@@ -1146,10 +1228,17 @@ impl AgentPanel {
             last_configuration_error_telemetry: None,
             on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
             _active_view_observation: None,
+            sidebar: None,
         };
 
         // Initial sync of agent servers from extensions
         panel.sync_agent_servers_from_extensions(cx);
+
+        cx.defer_in(window, move |this, window, cx| {
+            this.sidebar = find_or_create_sidebar_for_window(window, cx);
+            cx.notify();
+        });
+
         panel
     }
 
@@ -2395,7 +2484,7 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let selected_agent = AgentType::from(ext_agent);
+        let selected_agent = AgentType::from(ext_agent.clone());
         if self.selected_agent != selected_agent {
             self.selected_agent = selected_agent;
             self.serialize(cx);
@@ -2406,9 +2495,13 @@ impl AgentPanel {
             .is_some()
             .then(|| self.thread_store.clone());
 
+        let connection_store = self.connection_store.clone();
+
         let server_view = cx.new(|cx| {
             crate::ConnectionView::new(
                 server,
+                connection_store,
+                ext_agent,
                 resume_session_id,
                 cwd,
                 title,
@@ -2956,7 +3049,7 @@ impl Focusable for AgentPanel {
             ActiveView::Uninitialized => self.focus_handle.clone(),
             ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx),
             ActiveView::History { kind } => match kind {
-                HistoryKind::AgentThreads => self.acp_history.focus_handle(cx),
+                HistoryKind::AgentThreads => self.acp_history_view.focus_handle(cx),
                 HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
             },
             ActiveView::TextThread {
@@ -3519,9 +3612,109 @@ impl AgentPanel {
             })
     }
 
+    fn sidebar_info(&self, cx: &App) -> Option<(AnyView, Pixels, bool)> {
+        if !multi_workspace_enabled(cx) {
+            return None;
+        }
+        let sidebar = self.sidebar.as_ref()?;
+        let is_open = sidebar.read(cx).is_open();
+        let width = sidebar.read(cx).width(cx);
+        let view: AnyView = sidebar.clone().into();
+        Some((view, width, is_open))
+    }
+
+    fn render_sidebar_toggle(&self, cx: &Context<Self>) -> Option<AnyElement> {
+        if !multi_workspace_enabled(cx) {
+            return None;
+        }
+        let sidebar = self.sidebar.as_ref()?;
+        let sidebar_read = sidebar.read(cx);
+        if sidebar_read.is_open() {
+            return None;
+        }
+        let has_notifications = sidebar_read.has_notifications(cx);
+
+        Some(
+            IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
+                .icon_size(IconSize::Small)
+                .when(has_notifications, |button| {
+                    button
+                        .indicator(Indicator::dot().color(Color::Accent))
+                        .indicator_border_color(Some(cx.theme().colors().tab_bar_background))
+                })
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
+                })
+                .on_click(|_, window, cx| {
+                    window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+                })
+                .into_any_element(),
+        )
+    }
+
+    fn render_sidebar(&self, cx: &Context<Self>) -> Option<AnyElement> {
+        let (sidebar_view, sidebar_width, is_open) = self.sidebar_info(cx)?;
+        if !is_open {
+            return None;
+        }
+
+        let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
+        let sidebar = self.sidebar.as_ref()?.downgrade();
+
+        let resize_handle = deferred(
+            div()
+                .id("sidebar-resize-handle")
+                .absolute()
+                .when(docked_right, |this| {
+                    this.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+                })
+                .when(!docked_right, |this| {
+                    this.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+                })
+                .top(px(0.))
+                .h_full()
+                .w(SIDEBAR_RESIZE_HANDLE_SIZE)
+                .cursor_col_resize()
+                .on_drag(DraggedSidebar, |dragged, _, _, cx| {
+                    cx.stop_propagation();
+                    cx.new(|_| dragged.clone())
+                })
+                .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                    cx.stop_propagation();
+                })
+                .on_mouse_up(MouseButton::Left, move |event, _, cx| {
+                    if event.click_count == 2 {
+                        sidebar
+                            .update(cx, |sidebar, cx| {
+                                sidebar.set_width(None, cx);
+                            })
+                            .ok();
+                        cx.stop_propagation();
+                    }
+                })
+                .occlude(),
+        );
+
+        Some(
+            div()
+                .id("sidebar-container")
+                .relative()
+                .h_full()
+                .w(sidebar_width)
+                .flex_shrink_0()
+                .when(docked_right, |this| this.border_l_1())
+                .when(!docked_right, |this| this.border_r_1())
+                .border_color(cx.theme().colors().border)
+                .child(sidebar_view)
+                .child(resize_handle)
+                .into_any_element(),
+        )
+    }
+
     fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let agent_server_store = self.project.read(cx).agent_server_store().clone();
         let focus_handle = self.focus_handle(cx);
+        let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
 
         let (selected_agent_custom_icon, selected_agent_label) =
             if let AgentType::Custom { name, .. } = &self.selected_agent {
@@ -3984,6 +4177,9 @@ impl AgentPanel {
                         .size_full()
                         .gap(DynamicSpacing::Base04.rems(cx))
                         .pl(DynamicSpacing::Base04.rems(cx))
+                        .when(!docked_right, |this| {
+                            this.children(self.render_sidebar_toggle(cx))
+                        })
                         .child(agent_selector_menu)
                         .child(self.render_start_thread_in_selector(cx)),
                 )
@@ -4000,7 +4196,10 @@ impl AgentPanel {
                                 cx,
                             ))
                         })
-                        .child(self.render_panel_options_menu(window, cx)),
+                        .child(self.render_panel_options_menu(window, cx))
+                        .when(docked_right, |this| {
+                            this.children(self.render_sidebar_toggle(cx))
+                        }),
                 )
                 .into_any_element()
         } else {
@@ -4038,6 +4237,9 @@ impl AgentPanel {
                         .size_full()
                         .gap(DynamicSpacing::Base04.rems(cx))
                         .pl(DynamicSpacing::Base04.rems(cx))
+                        .when(!docked_right, |this| {
+                            this.children(self.render_sidebar_toggle(cx))
+                        })
                         .child(match &self.active_view {
                             ActiveView::History { .. } | ActiveView::Configuration => {
                                 self.render_toolbar_back_button(cx).into_any_element()
@@ -4060,7 +4262,10 @@ impl AgentPanel {
                                 cx,
                             ))
                         })
-                        .child(self.render_panel_options_menu(window, cx)),
+                        .child(self.render_panel_options_menu(window, cx))
+                        .when(docked_right, |this| {
+                            this.children(self.render_sidebar_toggle(cx))
+                        }),
                 )
                 .into_any_element()
         }
@@ -4561,7 +4766,7 @@ impl Render for AgentPanel {
                         .child(server_view.clone())
                         .child(self.render_drag_target(cx)),
                     ActiveView::History { kind } => match kind {
-                        HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
+                        HistoryKind::AgentThreads => parent.child(self.acp_history_view.clone()),
                         HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
                     },
                     ActiveView::TextThread {
@@ -4600,14 +4805,44 @@ impl Render for AgentPanel {
             })
             .children(self.render_trial_end_upsell(window, cx));
 
+        let sidebar = self.render_sidebar(cx);
+        let has_sidebar = sidebar.is_some();
+        let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
+
+        let panel = h_flex()
+            .size_full()
+            .when(has_sidebar, |this| {
+                this.on_drag_move(cx.listener(
+                    move |this, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
+                        if let Some(sidebar) = &this.sidebar {
+                            let width = if docked_right {
+                                e.bounds.right() - e.event.position.x
+                            } else {
+                                e.event.position.x
+                            };
+                            sidebar.update(cx, |sidebar, cx| {
+                                sidebar.set_width(Some(width), cx);
+                            });
+                        }
+                    },
+                ))
+            })
+            .map(|this| {
+                if docked_right {
+                    this.child(content).children(sidebar)
+                } else {
+                    this.children(sidebar).child(content)
+                }
+            });
+
         match self.active_view.which_font_size_used() {
             WhichFontSize::AgentFont => {
                 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
                     .size_full()
-                    .child(content)
+                    .child(panel)
                     .into_any()
             }
-            _ => content.into_any(),
+            _ => panel.into_any(),
         }
     }
 }

crates/agent_ui/src/agent_ui.rs 🔗

@@ -1,4 +1,5 @@
 mod agent_configuration;
+pub(crate) mod agent_connection_store;
 mod agent_diff;
 mod agent_model_selector;
 mod agent_panel;
@@ -22,6 +23,7 @@ mod mode_selector;
 mod model_selector;
 mod model_selector_popover;
 mod profile_selector;
+pub mod sidebar;
 mod slash_command;
 mod slash_command_picker;
 mod terminal_codegen;
@@ -31,6 +33,7 @@ pub mod test_support;
 mod text_thread_editor;
 mod text_thread_history;
 mod thread_history;
+mod thread_history_view;
 mod ui;
 
 use std::rc::Rc;
@@ -72,7 +75,8 @@ pub(crate) use mode_selector::ModeSelector;
 pub(crate) use model_selector::ModelSelector;
 pub(crate) use model_selector_popover::ModelSelectorPopover;
 pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
-pub(crate) use thread_history::*;
+pub(crate) use thread_history::ThreadHistory;
+pub(crate) use thread_history_view::*;
 use zed_actions;
 
 actions!(
@@ -212,7 +216,7 @@ pub struct NewNativeAgentThreadFromSummary {
 }
 
 // TODO unify this with AgentType
-#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum ExternalAgent {
     NativeAgent,

crates/agent_ui/src/completion_provider.rs 🔗

@@ -64,6 +64,7 @@ pub(crate) enum PromptContextType {
     Thread,
     Rules,
     Diagnostics,
+    BranchDiff,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -102,6 +103,7 @@ impl TryFrom<&str> for PromptContextType {
             "thread" => Ok(Self::Thread),
             "rule" => Ok(Self::Rules),
             "diagnostics" => Ok(Self::Diagnostics),
+            "diff" => Ok(Self::BranchDiff),
             _ => Err(format!("Invalid context picker mode: {}", value)),
         }
     }
@@ -116,6 +118,7 @@ impl PromptContextType {
             Self::Thread => "thread",
             Self::Rules => "rule",
             Self::Diagnostics => "diagnostics",
+            Self::BranchDiff => "branch diff",
         }
     }
 
@@ -127,6 +130,7 @@ impl PromptContextType {
             Self::Thread => "Threads",
             Self::Rules => "Rules",
             Self::Diagnostics => "Diagnostics",
+            Self::BranchDiff => "Branch Diff",
         }
     }
 
@@ -138,6 +142,7 @@ impl PromptContextType {
             Self::Thread => IconName::Thread,
             Self::Rules => IconName::Reader,
             Self::Diagnostics => IconName::Warning,
+            Self::BranchDiff => IconName::GitBranch,
         }
     }
 }
@@ -150,6 +155,12 @@ pub(crate) enum Match {
     Fetch(SharedString),
     Rules(RulesContextEntry),
     Entry(EntryMatch),
+    BranchDiff(BranchDiffMatch),
+}
+
+#[derive(Debug, Clone)]
+pub struct BranchDiffMatch {
+    pub base_ref: SharedString,
 }
 
 impl Match {
@@ -162,6 +173,7 @@ impl Match {
             Match::Symbol(_) => 1.,
             Match::Rules(_) => 1.,
             Match::Fetch(_) => 1.,
+            Match::BranchDiff(_) => 1.,
         }
     }
 }
@@ -781,6 +793,47 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
         }
     }
 
+    fn build_branch_diff_completion(
+        base_ref: SharedString,
+        source_range: Range<Anchor>,
+        source: Arc<T>,
+        editor: WeakEntity<Editor>,
+        mention_set: WeakEntity<MentionSet>,
+        workspace: Entity<Workspace>,
+        cx: &mut App,
+    ) -> Completion {
+        let uri = MentionUri::GitDiff {
+            base_ref: base_ref.to_string(),
+        };
+        let crease_text: SharedString = format!("Branch Diff (vs {})", base_ref).into();
+        let display_text = format!("@{}", crease_text);
+        let new_text = format!("[{}]({}) ", display_text, uri.to_uri());
+        let new_text_len = new_text.len();
+        let icon_path = uri.icon_path(cx);
+
+        Completion {
+            replace_range: source_range.clone(),
+            new_text,
+            label: CodeLabel::plain(crease_text.to_string(), None),
+            documentation: None,
+            source: project::CompletionSource::Custom,
+            icon_path: Some(icon_path),
+            match_start: None,
+            snippet_deduplication_key: None,
+            insert_text_mode: None,
+            confirm: Some(confirm_completion_callback(
+                crease_text,
+                source_range.start,
+                new_text_len - 1,
+                uri,
+                source,
+                editor,
+                mention_set,
+                workspace,
+            )),
+        }
+    }
+
     fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
         let commands = self.source.available_commands(cx);
         if commands.is_empty() {
@@ -812,6 +865,27 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
         })
     }
 
+    fn fetch_branch_diff_match(
+        &self,
+        workspace: &Entity<Workspace>,
+        cx: &mut App,
+    ) -> Option<Task<Option<BranchDiffMatch>>> {
+        let project = workspace.read(cx).project().clone();
+        let repo = project.read(cx).active_repository(cx)?;
+
+        let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
+
+        Some(cx.spawn(async move |_cx| {
+            let base_ref = default_branch_receiver
+                .await
+                .ok()
+                .and_then(|r| r.ok())
+                .flatten()?;
+
+            Some(BranchDiffMatch { base_ref })
+        }))
+    }
+
     fn search_mentions(
         &self,
         mode: Option<PromptContextType>,
@@ -892,6 +966,8 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
 
             Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()),
 
+            Some(PromptContextType::BranchDiff) => Task::ready(Vec::new()),
+
             None if query.is_empty() => {
                 let recent_task = self.recent_context_picker_entries(&workspace, cx);
                 let entries = self
@@ -905,9 +981,25 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                     })
                     .collect::<Vec<_>>();
 
+                let branch_diff_task = if self
+                    .source
+                    .supports_context(PromptContextType::BranchDiff, cx)
+                {
+                    self.fetch_branch_diff_match(&workspace, cx)
+                } else {
+                    None
+                };
+
                 cx.spawn(async move |_cx| {
                     let mut matches = recent_task.await;
                     matches.extend(entries);
+
+                    if let Some(branch_diff_task) = branch_diff_task {
+                        if let Some(branch_diff_match) = branch_diff_task.await {
+                            matches.push(Match::BranchDiff(branch_diff_match));
+                        }
+                    }
+
                     matches
                 })
             }
@@ -924,7 +1016,16 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                     .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
                     .collect::<Vec<_>>();
 
-                cx.background_spawn(async move {
+                let branch_diff_task = if self
+                    .source
+                    .supports_context(PromptContextType::BranchDiff, cx)
+                {
+                    self.fetch_branch_diff_match(&workspace, cx)
+                } else {
+                    None
+                };
+
+                cx.spawn(async move |cx| {
                     let mut matches = search_files_task
                         .await
                         .into_iter()
@@ -949,6 +1050,26 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                         })
                     }));
 
+                    if let Some(branch_diff_task) = branch_diff_task {
+                        let branch_diff_keyword = PromptContextType::BranchDiff.keyword();
+                        let branch_diff_matches = fuzzy::match_strings(
+                            &[StringMatchCandidate::new(0, branch_diff_keyword)],
+                            &query,
+                            false,
+                            true,
+                            1,
+                            &Arc::new(AtomicBool::default()),
+                            cx.background_executor().clone(),
+                        )
+                        .await;
+
+                        if !branch_diff_matches.is_empty() {
+                            if let Some(branch_diff_match) = branch_diff_task.await {
+                                matches.push(Match::BranchDiff(branch_diff_match));
+                            }
+                        }
+                    }
+
                     matches.sort_by(|a, b| {
                         b.score()
                             .partial_cmp(&a.score())
@@ -1364,6 +1485,17 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
                                         cx,
                                     )
                                 }
+                                Match::BranchDiff(branch_diff) => {
+                                    Some(Self::build_branch_diff_completion(
+                                        branch_diff.base_ref,
+                                        source_range.clone(),
+                                        source.clone(),
+                                        editor.clone(),
+                                        mention_set.clone(),
+                                        workspace.clone(),
+                                        cx,
+                                    ))
+                                }
                             })
                             .collect::<Vec<_>>()
                     });

crates/agent_ui/src/connection_view.rs 🔗

@@ -5,10 +5,12 @@ use acp_thread::{
     UserMessageId,
 };
 use acp_thread::{AgentConnection, Plan};
-use action_log::{ActionLog, ActionLogTelemetry};
+use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
 use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore};
 use agent_client_protocol::{self as acp, PromptCapabilities};
-use agent_servers::{AgentServer, AgentServerDelegate};
+use agent_servers::AgentServer;
+#[cfg(test)]
+use agent_servers::AgentServerDelegate;
 use agent_settings::{AgentProfileId, AgentSettings};
 use anyhow::{Result, anyhow};
 use arrayvec::ArrayVec;
@@ -44,7 +46,7 @@ use std::sync::Arc;
 use std::time::Instant;
 use std::{collections::BTreeMap, rc::Rc, time::Duration};
 use terminal_view::terminal_panel::TerminalPanel;
-use text::{Anchor, ToPoint as _};
+use text::Anchor;
 use theme::AgentFontSize;
 use ui::{
     Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
@@ -65,6 +67,7 @@ use super::entry_view_state::EntryViewState;
 use super::thread_history::ThreadHistory;
 use crate::ModeSelector;
 use crate::ModelSelectorPopover;
+use crate::agent_connection_store::{AgentConnectionStore, ConnectionEntryEvent};
 use crate::agent_diff::AgentDiff;
 use crate::entry_view_state::{EntryViewEvent, ViewEvent};
 use crate::message_editor::{MessageEditor, MessageEditorEvent};
@@ -73,10 +76,10 @@ use crate::ui::{AgentNotification, AgentNotificationEvent};
 use crate::{
     AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall,
     ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort,
-    EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu,
-    OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SendImmediately,
-    SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu,
-    ToggleThinkingMode, UndoLastReject,
+    EditFirstQueuedMessage, ExpandMessageEditor, ExternalAgent, Follow, KeepAll, NewThread,
+    OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
+    RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode,
+    ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject,
 };
 
 const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
@@ -303,6 +306,8 @@ impl EventEmitter<AcpServerViewEvent> for ConnectionView {}
 
 pub struct ConnectionView {
     agent: Rc<dyn AgentServer>,
+    connection_store: Entity<AgentConnectionStore>,
+    connection_key: ExternalAgent,
     agent_server_store: Entity<AgentServerStore>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
@@ -414,6 +419,7 @@ pub struct ConnectedServerState {
     threads: HashMap<acp::SessionId, Entity<ThreadView>>,
     connection: Rc<dyn AgentConnection>,
     conversation: Entity<Conversation>,
+    _connection_entry_subscription: Subscription,
 }
 
 enum AuthState {
@@ -434,9 +440,7 @@ impl AuthState {
 
 struct LoadingView {
     session_id: Option<acp::SessionId>,
-    title: SharedString,
     _load_task: Task<()>,
-    _update_title_task: Task<anyhow::Result<()>>,
 }
 
 impl ConnectedServerState {
@@ -459,7 +463,7 @@ impl ConnectedServerState {
         let tasks = self
             .threads
             .keys()
-            .map(|id| self.connection.close_session(id, cx));
+            .map(|id| self.connection.clone().close_session(id, cx));
         let task = futures::future::join_all(tasks);
         cx.background_spawn(async move {
             task.await;
@@ -470,6 +474,8 @@ impl ConnectedServerState {
 impl ConnectionView {
     pub fn new(
         agent: Rc<dyn AgentServer>,
+        connection_store: Entity<AgentConnectionStore>,
+        connection_key: ExternalAgent,
         resume_session_id: Option<acp::SessionId>,
         cwd: Option<PathBuf>,
         title: Option<SharedString>,
@@ -509,6 +515,8 @@ impl ConnectionView {
 
         Self {
             agent: agent.clone(),
+            connection_store: connection_store.clone(),
+            connection_key: connection_key.clone(),
             agent_server_store,
             workspace,
             project: project.clone(),
@@ -516,6 +524,8 @@ impl ConnectionView {
             prompt_store,
             server_state: Self::initial_state(
                 agent.clone(),
+                connection_store,
+                connection_key,
                 resume_session_id,
                 cwd,
                 title,
@@ -558,6 +568,8 @@ impl ConnectionView {
 
         let state = Self::initial_state(
             self.agent.clone(),
+            self.connection_store.clone(),
+            self.connection_key.clone(),
             resume_session_id,
             cwd,
             title,
@@ -584,6 +596,8 @@ impl ConnectionView {
 
     fn initial_state(
         agent: Rc<dyn AgentServer>,
+        connection_store: Entity<AgentConnectionStore>,
+        connection_key: ExternalAgent,
         resume_session_id: Option<acp::SessionId>,
         cwd: Option<PathBuf>,
         title: Option<SharedString>,
@@ -640,29 +654,31 @@ impl ConnectionView {
             .or_else(|| worktree_roots.first().cloned())
             .unwrap_or_else(|| paths::home_dir().as_path().into());
 
-        let (status_tx, mut status_rx) = watch::channel("Loading…".into());
-        let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
-        let delegate = AgentServerDelegate::new(
-            project.read(cx).agent_server_store().clone(),
-            project.clone(),
-            Some(status_tx),
-            Some(new_version_available_tx),
-        );
+        let connection_entry = connection_store.update(cx, |store, cx| {
+            store.request_connection(connection_key, agent.clone(), cx)
+        });
+
+        let connection_entry_subscription =
+            cx.subscribe(&connection_entry, |this, _entry, event, cx| match event {
+                ConnectionEntryEvent::NewVersionAvailable(version) => {
+                    if let Some(thread) = this.active_thread() {
+                        thread.update(cx, |thread, cx| {
+                            thread.new_server_version_available = Some(version.clone());
+                            cx.notify();
+                        });
+                    }
+                }
+            });
+
+        let connect_result = connection_entry.read(cx).wait_for_connection();
 
-        let connect_task = agent.connect(delegate, cx);
         let load_session_id = resume_session_id.clone();
         let load_task = cx.spawn_in(window, async move |this, cx| {
-            let connection = match connect_task.await {
+            let connection = match connect_result.await {
                 Ok(connection) => connection,
                 Err(err) => {
                     this.update_in(cx, |this, window, cx| {
-                        if err.downcast_ref::<LoadError>().is_some() {
-                            this.handle_load_error(load_session_id.clone(), err, window, cx);
-                        } else if let Some(active) = this.active_thread() {
-                            active.update(cx, |active, cx| active.handle_thread_error(err, cx));
-                        } else {
-                            this.handle_load_error(load_session_id.clone(), err, window, cx);
-                        }
+                        this.handle_load_error(load_session_id.clone(), err, window, cx);
                         cx.notify();
                     })
                     .log_err();
@@ -776,52 +792,27 @@ impl ConnectionView {
                                 active_id: Some(id.clone()),
                                 threads: HashMap::from_iter([(id, current)]),
                                 conversation,
+                                _connection_entry_subscription: connection_entry_subscription,
                             }),
                             cx,
                         );
                     }
                     Err(err) => {
-                        this.handle_load_error(load_session_id.clone(), err, window, cx);
+                        this.handle_load_error(
+                            load_session_id.clone(),
+                            LoadError::Other(err.to_string().into()),
+                            window,
+                            cx,
+                        );
                     }
                 };
             })
             .log_err();
         });
 
-        cx.spawn(async move |this, cx| {
-            while let Ok(new_version) = new_version_available_rx.recv().await {
-                if let Some(new_version) = new_version {
-                    this.update(cx, |this, cx| {
-                        if let Some(thread) = this.active_thread() {
-                            thread.update(cx, |thread, _cx| {
-                                thread.new_server_version_available = Some(new_version.into());
-                            });
-                        }
-                        cx.notify();
-                    })
-                    .ok();
-                }
-            }
-        })
-        .detach();
-
-        let loading_view = cx.new(|cx| {
-            let update_title_task = cx.spawn(async move |this, cx| {
-                loop {
-                    let status = status_rx.recv().await?;
-                    this.update(cx, |this: &mut LoadingView, cx| {
-                        this.title = status;
-                        cx.notify();
-                    })?;
-                }
-            });
-
-            LoadingView {
-                session_id: resume_session_id,
-                title: "Loading…".into(),
-                _load_task: load_task,
-                _update_title_task: update_title_task,
-            }
+        let loading_view = cx.new(|_cx| LoadingView {
+            session_id: resume_session_id,
+            _load_task: load_task,
         });
 
         ServerState::Loading(loading_view)
@@ -1099,6 +1090,7 @@ impl ConnectionView {
                         threads: HashMap::default(),
                         connection,
                         conversation: cx.new(|_cx| Conversation::default()),
+                        _connection_entry_subscription: Subscription::new(|| {}),
                     }),
                     cx,
                 );
@@ -1111,7 +1103,7 @@ impl ConnectionView {
     fn handle_load_error(
         &mut self,
         session_id: Option<acp::SessionId>,
-        err: anyhow::Error,
+        err: LoadError,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -1125,15 +1117,10 @@ impl ConnectionView {
                 self.focus_handle.focus(window, cx)
             }
         }
-        let load_error = if let Some(load_err) = err.downcast_ref::<LoadError>() {
-            load_err.clone()
-        } else {
-            LoadError::Other(format!("{:#}", err).into())
-        };
-        self.emit_load_error_telemetry(&load_error);
+        self.emit_load_error_telemetry(&err);
         self.set_server_state(
             ServerState::LoadError {
-                error: load_error,
+                error: err,
                 session_id,
             },
             cx,
@@ -1172,10 +1159,10 @@ impl ConnectionView {
         &self.workspace
     }
 
-    pub fn title(&self, cx: &App) -> SharedString {
+    pub fn title(&self, _cx: &App) -> SharedString {
         match &self.server_state {
             ServerState::Connected(_) => "New Thread".into(),
-            ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(),
+            ServerState::Loading(_) => "Loading…".into(),
             ServerState::LoadError { error, .. } => match error {
                 LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
                 LoadError::FailedToInstall(_) => {
@@ -1444,7 +1431,7 @@ impl ConnectionView {
                     .connection()
                     .auth_methods()
                     .iter()
-                    .any(|method| method.id.0.as_ref() == "claude-login")
+                    .any(|method| method.id().0.as_ref() == "claude-login")
                 {
                     available_commands.push(acp::AvailableCommand::new("login", "Authenticate"));
                     available_commands.push(acp::AvailableCommand::new("logout", "Authenticate"));
@@ -1508,10 +1495,15 @@ impl ConnectionView {
         let agent_telemetry_id = connection.telemetry_id();
 
         // Check for the experimental "terminal-auth" _meta field
-        let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
+        let auth_method = connection.auth_methods().iter().find(|m| m.id() == &method);
 
         if let Some(terminal_auth) = auth_method
-            .and_then(|a| a.meta.as_ref())
+            .and_then(|a| match a {
+                acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(),
+                acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(),
+                acp::AuthMethod::Agent(agent) => agent.meta.as_ref(),
+                _ => None,
+            })
             .and_then(|m| m.get("terminal-auth"))
         {
             // Extract terminal auth details from meta
@@ -1895,7 +1887,7 @@ impl ConnectionView {
                     .enumerate()
                     .rev()
                     .map(|(ix, method)| {
-                        let (method_id, name) = (method.id.0.clone(), method.name.clone());
+                        let (method_id, name) = (method.id().0.clone(), method.name().to_string());
                         let agent_telemetry_id = connection.telemetry_id();
 
                         Button::new(method_id.clone(), name)
@@ -1907,8 +1899,8 @@ impl ConnectionView {
                                     this.style(ButtonStyle::Outlined)
                                 }
                             })
-                            .when_some(method.description.clone(), |this, description| {
-                                this.tooltip(Tooltip::text(description))
+                            .when_some(method.description(), |this, description| {
+                                this.tooltip(Tooltip::text(description.to_string()))
                             })
                             .on_click({
                                 cx.listener(move |this, _, window, cx| {
@@ -2353,7 +2345,7 @@ impl ConnectionView {
         }
 
         if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
-            multi_workspace.read(cx).is_sidebar_open()
+            crate::agent_panel::sidebar_is_open(window, cx)
                 || self.agent_panel_visible(&multi_workspace, cx)
         } else {
             self.workspace
@@ -2909,12 +2901,18 @@ pub(crate) mod tests {
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
         // Create history without an initial session list - it will be set after connection
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::default_response()),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     None,
                     None,
                     None,
@@ -3009,12 +3007,18 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     Some(SessionId::new("resume-session")),
                     None,
                     None,
@@ -3062,12 +3066,18 @@ pub(crate) mod tests {
         let captured_cwd = connection.captured_cwd.clone();
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let _thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(connection)),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     Some(SessionId::new("session-1")),
                     Some(PathBuf::from("/project/subdir")),
                     None,
@@ -3113,12 +3123,18 @@ pub(crate) mod tests {
         let captured_cwd = connection.captured_cwd.clone();
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let _thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(connection)),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     Some(SessionId::new("session-1")),
                     Some(PathBuf::from("/some/other/path")),
                     None,
@@ -3164,12 +3180,18 @@ pub(crate) mod tests {
         let captured_cwd = connection.captured_cwd.clone();
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let _thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(connection)),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     Some(SessionId::new("session-1")),
                     Some(PathBuf::from("/project/../outside")),
                     None,
@@ -3476,13 +3498,19 @@ pub(crate) mod tests {
 
         // Set up thread view in workspace 1
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
 
         let agent = StubAgentServer::default_response();
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(agent),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     None,
                     None,
                     None,
@@ -3690,12 +3718,18 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(agent),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     None,
                     None,
                     None,
@@ -3753,8 +3787,16 @@ pub(crate) mod tests {
     }
 
     impl Render for ThreadViewItem {
-        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-            self.0.clone().into_any_element()
+        fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+            // Render the title editor in the element tree too. In the real app
+            // it is part of the agent panel
+            let title_editor = self
+                .0
+                .read(cx)
+                .active_thread()
+                .map(|t| t.read(cx).title_editor.clone());
+
+            v_flex().children(title_editor).child(self.0.clone())
         }
     }
 
@@ -4037,7 +4079,10 @@ pub(crate) mod tests {
         fn new() -> Self {
             Self {
                 authenticated: Arc::new(Mutex::new(false)),
-                auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"),
+                auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
+                    Self::AUTH_METHOD_ID,
+                    "Test Login",
+                )),
             }
         }
     }
@@ -4090,7 +4135,7 @@ pub(crate) mod tests {
             method_id: acp::AuthMethodId,
             _cx: &mut App,
         ) -> Task<gpui::Result<()>> {
-            if method_id == self.auth_method.id {
+            if &method_id == self.auth_method.id() {
                 *self.authenticated.lock() = true;
                 Task::ready(Ok(()))
             } else {
@@ -4409,13 +4454,19 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let connection = Rc::new(StubAgentConnection::new());
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(connection.as_ref().clone())),
+                    connection_store,
+                    ExternalAgent::Custom {
+                        name: "Test".into(),
+                    },
                     None,
                     None,
                     None,
@@ -6025,6 +6076,7 @@ pub(crate) mod tests {
         init_test(cx);
 
         let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
+        add_to_workspace(thread_view.clone(), cx);
 
         let active = active_thread(&thread_view, cx);
         let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
@@ -6034,9 +6086,12 @@ pub(crate) mod tests {
             assert!(!editor.read_only(cx));
         });
 
-        title_editor.update_in(cx, |editor, window, cx| {
-            editor.set_text("My Custom Title", window, cx);
-        });
+        cx.focus(&thread_view);
+        cx.focus(&title_editor);
+
+        cx.dispatch_action(editor::actions::DeleteLine);
+        cx.simulate_input("My Custom Title");
+
         cx.run_until_parked();
 
         title_editor.read_with(cx, |editor, cx| {

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

@@ -156,43 +156,6 @@ impl ThreadFeedbackState {
     }
 }
 
-#[derive(Default, Clone, Copy)]
-struct DiffStats {
-    lines_added: u32,
-    lines_removed: u32,
-}
-
-impl DiffStats {
-    fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
-        let mut stats = DiffStats::default();
-        let diff_snapshot = diff.snapshot(cx);
-        let buffer_snapshot = buffer.snapshot();
-        let base_text = diff_snapshot.base_text();
-
-        for hunk in diff_snapshot.hunks(&buffer_snapshot) {
-            let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
-            stats.lines_added += added_rows;
-
-            let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
-            let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
-            let removed_rows = base_end.saturating_sub(base_start);
-            stats.lines_removed += removed_rows;
-        }
-
-        stats
-    }
-
-    fn all_files(changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>, cx: &App) -> Self {
-        let mut total = DiffStats::default();
-        for (buffer, diff) in changed_buffers {
-            let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
-            total.lines_added += stats.lines_added;
-            total.lines_removed += stats.lines_removed;
-        }
-        total
-    }
-}
-
 pub enum AcpThreadViewEvent {
     FirstSendRequested { content: Vec<acp::ContentBlock> },
 }
@@ -1464,6 +1427,13 @@ impl ThreadView {
 
         match event {
             EditorEvent::BufferEdited => {
+                // We only want to set the title if the user has actively edited
+                // it. If the title editor is not focused, we programmatically
+                // changed the text, so we don't want to set the title again.
+                if !title_editor.read(cx).is_focused(window) {
+                    return;
+                }
+
                 let new_title = title_editor.read(cx).text(cx);
                 thread.update(cx, |thread, cx| {
                     thread
@@ -7439,7 +7409,7 @@ impl ThreadView {
                                     // TODO: Add keyboard navigation.
                                     let is_hovered =
                                         self.hovered_recent_history_item == Some(index);
-                                    crate::thread_history::HistoryEntryElement::new(
+                                    crate::thread_history_view::HistoryEntryElement::new(
                                         entry,
                                         self.server_view.clone(),
                                     )

crates/agent_ui/src/entry_view_state.rs 🔗

@@ -508,8 +508,7 @@ mod tests {
         });
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let view_state = cx.new(|_cx| {
             EntryViewState::new(

crates/agent_ui/src/inline_assistant.rs 🔗

@@ -2155,7 +2155,7 @@ pub mod test {
             });
 
             let thread_store = cx.new(|cx| ThreadStore::new(cx));
-            let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx));
+            let history = cx.new(|cx| crate::ThreadHistory::new(None, cx));
 
             // Add editor to workspace
             workspace.update(cx, |workspace, cx| {

crates/agent_ui/src/mention_set.rs 🔗

@@ -147,10 +147,12 @@ impl MentionSet {
                 include_errors,
                 include_warnings,
             } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
+            MentionUri::GitDiff { base_ref } => {
+                self.confirm_mention_for_git_diff(base_ref.into(), cx)
+            }
             MentionUri::PastedImage
             | MentionUri::Selection { .. }
             | MentionUri::TerminalSelection { .. }
-            | MentionUri::GitDiff { .. }
             | MentionUri::MergeConflict { .. } => {
                 Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
             }
@@ -298,9 +300,8 @@ impl MentionSet {
                 debug_panic!("unexpected terminal URI");
                 Task::ready(Err(anyhow!("unexpected terminal URI")))
             }
-            MentionUri::GitDiff { .. } => {
-                debug_panic!("unexpected git diff URI");
-                Task::ready(Err(anyhow!("unexpected git diff URI")))
+            MentionUri::GitDiff { base_ref } => {
+                self.confirm_mention_for_git_diff(base_ref.into(), cx)
             }
             MentionUri::MergeConflict { .. } => {
                 debug_panic!("unexpected merge conflict URI");
@@ -553,19 +554,17 @@ impl MentionSet {
             project.read(cx).fs().clone(),
             thread_store,
         ));
-        let delegate = AgentServerDelegate::new(
-            project.read(cx).agent_server_store().clone(),
-            project.clone(),
-            None,
-            None,
-        );
+        let delegate =
+            AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None);
         let connection = server.connect(delegate, cx);
         cx.spawn(async move |_, cx| {
             let agent = connection.await?;
             let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
             let summary = agent
                 .0
-                .update(cx, |agent, cx| agent.thread_summary(id, cx))
+                .update(cx, |agent, cx| {
+                    agent.thread_summary(id, project.clone(), cx)
+                })
                 .await?;
             Ok(Mention::Text {
                 content: summary.to_string(),
@@ -604,6 +603,42 @@ impl MentionSet {
             })
         })
     }
+
+    fn confirm_mention_for_git_diff(
+        &self,
+        base_ref: SharedString,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<Mention>> {
+        let Some(project) = self.project.upgrade() else {
+            return Task::ready(Err(anyhow!("project not found")));
+        };
+
+        let Some(repo) = project.read(cx).active_repository(cx) else {
+            return Task::ready(Err(anyhow!("no active repository")));
+        };
+
+        let diff_receiver = repo.update(cx, |repo, cx| {
+            repo.diff(
+                git::repository::DiffType::MergeBase { base_ref: base_ref },
+                cx,
+            )
+        });
+
+        cx.spawn(async move |_, _| {
+            let diff_text = diff_receiver.await??;
+            if diff_text.is_empty() {
+                Ok(Mention::Text {
+                    content: "No changes found in branch diff.".into(),
+                    tracked_buffers: Vec::new(),
+                })
+            } else {
+                Ok(Mention::Text {
+                    content: diff_text,
+                    tracked_buffers: Vec::new(),
+                })
+            }
+        })
+    }
 }
 
 #[cfg(test)]

crates/agent_ui/src/message_editor.rs 🔗

@@ -80,6 +80,7 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
                 PromptContextType::Diagnostics,
                 PromptContextType::Fetch,
                 PromptContextType::Rules,
+                PromptContextType::BranchDiff,
             ]);
         }
         supported
@@ -1707,8 +1708,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -1821,8 +1821,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let workspace_handle = workspace.downgrade();
         let message_editor = workspace.update_in(cx, |_, window, cx| {
             cx.new(|cx| {
@@ -1977,8 +1976,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
         let available_commands = Rc::new(RefCell::new(vec![
             acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
@@ -2212,8 +2210,7 @@ mod tests {
         }
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
@@ -2708,8 +2705,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2809,8 +2805,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let session_id = acp::SessionId::new("thread-123");
         let title = Some("Previous Conversation".into());
@@ -2885,8 +2880,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2942,8 +2936,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2997,8 +2990,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3053,8 +3045,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3118,8 +3109,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3278,8 +3268,7 @@ mod tests {
         });
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
         // to ensure we have a fixed viewport, so we can eventually actually
@@ -3399,8 +3388,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3482,8 +3470,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3567,8 +3554,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3720,8 +3706,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {

crates/sidebar/src/sidebar.rs → crates/agent_ui/src/sidebar.rs 🔗

@@ -1,34 +1,33 @@
+use crate::{AgentPanel, AgentPanelEvent, NewThread};
 use acp_thread::ThreadStatus;
+use action_log::DiffStats;
 use agent::ThreadStore;
 use agent_client_protocol as acp;
-use agent_ui::{AgentPanel, AgentPanelEvent, NewThread};
+use agent_settings::AgentSettings;
 use chrono::Utc;
+use db::kvp::KEY_VALUE_STORE;
 use editor::{Editor, EditorElement, EditorStyle};
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
 use gpui::{
-    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState,
+    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState,
     Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px,
     relative, rems,
 };
 use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::Event as ProjectEvent;
-use recent_projects::RecentProjects;
 use settings::Settings;
 use std::collections::{HashMap, HashSet};
 use std::mem;
 use theme::{ActiveTheme, ThemeSettings};
-use ui::utils::TRAFFIC_LIGHT_PADDING;
 use ui::{
-    AgentThreadStatus, ButtonStyle, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding,
-    ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar,
-    prelude::*,
+    AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
+    Tooltip, WithScrollbar, prelude::*,
 };
+use util::ResultExt as _;
 use util::path_list::PathList;
 use workspace::{
-    FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
-    SidebarEvent, ToggleWorkspaceSidebar, Workspace,
+    MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
 };
-use zed_actions::OpenRecent;
 use zed_actions::editor::{MoveDown, MoveUp};
 
 actions!(
@@ -45,6 +44,27 @@ const DEFAULT_WIDTH: Pixels = px(320.0);
 const MIN_WIDTH: Pixels = px(200.0);
 const MAX_WIDTH: Pixels = px(800.0);
 const DEFAULT_THREADS_SHOWN: usize = 5;
+const SIDEBAR_STATE_KEY: &str = "sidebar_state";
+
+fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
+    KEY_VALUE_STORE
+        .scoped(SIDEBAR_STATE_KEY)
+        .read(&multi_workspace_id.to_string())
+        .log_err()
+        .flatten()
+        .and_then(|json| serde_json::from_str::<bool>(&json).ok())
+        .unwrap_or(false)
+}
+
+async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
+    if let Ok(json) = serde_json::to_string(&is_open) {
+        KEY_VALUE_STORE
+            .scoped(SIDEBAR_STATE_KEY)
+            .write(multi_workspace_id.to_string(), json)
+            .await
+            .log_err();
+    }
+}
 
 #[derive(Clone, Debug)]
 struct ActiveThreadInfo {
@@ -54,6 +74,7 @@ struct ActiveThreadInfo {
     icon: IconName,
     icon_from_external_svg: Option<SharedString>,
     is_background: bool,
+    diff_stats: DiffStats,
 }
 
 impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
@@ -79,6 +100,7 @@ struct ThreadEntry {
     is_live: bool,
     is_background: bool,
     highlight_positions: Vec<usize>,
+    diff_stats: DiffStats,
 }
 
 #[derive(Clone)]
@@ -174,6 +196,8 @@ fn workspace_path_list_and_label(
 
 pub struct Sidebar {
     multi_workspace: WeakEntity<MultiWorkspace>,
+    persistence_key: Option<u64>,
+    is_open: bool,
     width: Pixels,
     focus_handle: FocusHandle,
     filter_editor: Entity<Editor>,
@@ -187,11 +211,8 @@ pub struct Sidebar {
     active_entry_index: Option<usize>,
     collapsed_groups: HashSet<PathList>,
     expanded_groups: HashMap<PathList, usize>,
-    recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
 }
 
-impl EventEmitter<SidebarEvent> for Sidebar {}
-
 impl Sidebar {
     pub fn new(
         multi_workspace: Entity<MultiWorkspace>,
@@ -213,7 +234,6 @@ impl Sidebar {
             window,
             |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
                 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
-                    this.focused_thread = None;
                     this.update_entries(cx);
                 }
                 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -271,8 +291,15 @@ impl Sidebar {
             this.update_entries(cx);
         });
 
+        let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
+        let is_open = persistence_key
+            .map(read_sidebar_open_state)
+            .unwrap_or(false);
+
         Self {
             multi_workspace: multi_workspace.downgrade(),
+            persistence_key,
+            is_open,
             width: DEFAULT_WIDTH,
             focus_handle,
             filter_editor,
@@ -283,7 +310,6 @@ impl Sidebar {
             active_entry_index: None,
             collapsed_groups: HashSet::new(),
             expanded_groups: HashMap::new(),
-            recent_projects_popover_handle: PopoverMenuHandle::default(),
         }
     }
 
@@ -335,31 +361,10 @@ impl Sidebar {
         cx.subscribe_in(
             agent_panel,
             window,
-            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
-                AgentPanelEvent::ActiveViewChanged => {
-                    match agent_panel.read(cx).active_connection_view() {
-                        Some(thread) => {
-                            if let Some(session_id) = thread.read(cx).parent_id(cx) {
-                                this.focused_thread = Some(session_id);
-                            }
-                        }
-                        None => {
-                            this.focused_thread = None;
-                        }
-                    }
-                    this.update_entries(cx);
-                }
-                AgentPanelEvent::ThreadFocused => {
-                    let new_focused = agent_panel
-                        .read(cx)
-                        .active_connection_view()
-                        .and_then(|thread| thread.read(cx).parent_id(cx));
-                    if new_focused.is_some() && new_focused != this.focused_thread {
-                        this.focused_thread = new_focused;
-                        this.update_entries(cx);
-                    }
-                }
-                AgentPanelEvent::BackgroundThreadChanged => {
+            |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+                AgentPanelEvent::ActiveViewChanged
+                | AgentPanelEvent::ThreadFocused
+                | AgentPanelEvent::BackgroundThreadChanged => {
                     this.update_entries(cx);
                 }
             },
@@ -400,6 +405,8 @@ impl Sidebar {
                     }
                 };
 
+                let diff_stats = thread.action_log().read(cx).diff_stats(cx);
+
                 ActiveThreadInfo {
                     session_id,
                     title,
@@ -407,6 +414,7 @@ impl Sidebar {
                     icon,
                     icon_from_external_svg,
                     is_background,
+                    diff_stats,
                 }
             })
             .collect()
@@ -420,6 +428,12 @@ impl Sidebar {
         let workspaces = mw.workspaces().to_vec();
         let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 
+        self.focused_thread = active_workspace
+            .as_ref()
+            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+            .and_then(|panel| panel.read(cx).active_connection_view().cloned())
+            .and_then(|cv| cv.read(cx).parent_id(cx));
+
         let thread_store = ThreadStore::try_global(cx);
         let query = self.filter_editor.read(cx).text(cx);
 
@@ -464,6 +478,7 @@ impl Sidebar {
                             is_live: false,
                             is_background: false,
                             highlight_positions: Vec::new(),
+                            diff_stats: DiffStats::default(),
                         });
                     }
                 }
@@ -489,6 +504,7 @@ impl Sidebar {
                         thread.icon_from_external_svg = info.icon_from_external_svg.clone();
                         thread.is_live = true;
                         thread.is_background = info.is_background;
+                        thread.diff_stats = info.diff_stats;
                     }
                 }
 
@@ -658,7 +674,7 @@ impl Sidebar {
         let Some(multi_workspace) = self.multi_workspace.upgrade() else {
             return;
         };
-        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
+        if !multi_workspace_enabled(cx) {
             return;
         }
 
@@ -795,17 +811,6 @@ impl Sidebar {
                 .into_any_element()
         };
 
-        let color = cx.theme().colors();
-        let base_bg = if is_active_workspace {
-            color.ghost_element_selected
-        } else {
-            color.panel_background
-        };
-        let gradient_overlay =
-            GradientFade::new(base_bg, color.element_hover, color.element_active)
-                .width(px(48.0))
-                .group_name(group_name.clone());
-
         ListItem::new(id)
             .group_name(group_name)
             .toggle_state(is_active_workspace)
@@ -822,9 +827,9 @@ impl Sidebar {
                             .size(IconSize::Small)
                             .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
                     )
-                    .child(label)
-                    .child(gradient_overlay),
+                    .child(label),
             )
+            .end_hover_gradient_overlay(true)
             .end_hover_slot(
                 h_flex()
                     .when(workspace_count > 1, |this| {
@@ -897,8 +902,6 @@ impl Sidebar {
             return;
         };
 
-        self.focused_thread = None;
-
         multi_workspace.update(cx, |multi_workspace, cx| {
             multi_workspace.activate(workspace.clone(), cx);
         });
@@ -1176,6 +1179,12 @@ impl Sidebar {
             .highlight_positions(thread.highlight_positions.to_vec())
             .status(thread.status)
             .notified(has_notification)
+            .when(thread.diff_stats.lines_added > 0, |this| {
+                this.added(thread.diff_stats.lines_added as usize)
+            })
+            .when(thread.diff_stats.lines_removed > 0, |this| {
+                this.removed(thread.diff_stats.lines_removed as usize)
+            })
             .selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
             .focused(is_selected)
             .on_click(cx.listener(move |this, _, window, cx| {
@@ -1185,48 +1194,6 @@ impl Sidebar {
             .into_any_element()
     }
 
-    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
-        let workspace = self
-            .multi_workspace
-            .upgrade()
-            .map(|mw| mw.read(cx).workspace().downgrade());
-
-        let focus_handle = workspace
-            .as_ref()
-            .and_then(|ws| ws.upgrade())
-            .map(|w| w.read(cx).focus_handle(cx))
-            .unwrap_or_else(|| cx.focus_handle());
-
-        let popover_handle = self.recent_projects_popover_handle.clone();
-
-        PopoverMenu::new("sidebar-recent-projects-menu")
-            .with_handle(popover_handle)
-            .menu(move |window, cx| {
-                workspace.as_ref().map(|ws| {
-                    RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx)
-                })
-            })
-            .trigger_with_tooltip(
-                IconButton::new("open-project", IconName::OpenFolder)
-                    .icon_size(IconSize::Small)
-                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
-                |_window, cx| {
-                    Tooltip::for_action(
-                        "Recent Projects",
-                        &OpenRecent {
-                            create_new_window: false,
-                        },
-                        cx,
-                    )
-                },
-            )
-            .anchor(gpui::Corner::TopLeft)
-            .offset(gpui::Point {
-                x: px(0.0),
-                y: px(2.0),
-            })
-    }
-
     fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
         let text_style = TextStyle {
@@ -1355,26 +1322,66 @@ impl Sidebar {
     }
 }
 
-impl WorkspaceSidebar for Sidebar {
-    fn width(&self, _cx: &App) -> Pixels {
-        self.width
+impl Sidebar {
+    pub fn is_open(&self) -> bool {
+        self.is_open
     }
 
-    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
-        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
+    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
+        if self.is_open == open {
+            return;
+        }
+        self.is_open = open;
         cx.notify();
+        if let Some(key) = self.persistence_key {
+            let is_open = self.is_open;
+            cx.background_spawn(async move {
+                save_sidebar_open_state(key, is_open).await;
+            })
+            .detach();
+        }
     }
 
-    fn has_notifications(&self, _cx: &App) -> bool {
-        !self.contents.notified_threads.is_empty()
+    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let new_state = !self.is_open;
+        self.set_open(new_state, cx);
+        if new_state {
+            cx.focus_self(window);
+        }
+    }
+
+    pub fn focus_or_unfocus(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.is_open {
+            let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
+            if sidebar_is_focused {
+                let active_pane = workspace.active_pane().clone();
+                let pane_focus = active_pane.read(cx).focus_handle(cx);
+                window.focus(&pane_focus, cx);
+            } else {
+                cx.focus_self(window);
+            }
+        } else {
+            self.set_open(true, cx);
+            cx.focus_self(window);
+        }
+    }
+
+    pub fn width(&self, _cx: &App) -> Pixels {
+        self.width
     }
 
-    fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
-        self.recent_projects_popover_handle.toggle(window, cx);
+    pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
+        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
+        cx.notify();
     }
 
-    fn is_recent_projects_popover_deployed(&self) -> bool {
-        self.recent_projects_popover_handle.is_deployed()
+    pub fn has_notifications(&self, _cx: &App) -> bool {
+        !self.contents.notified_threads.is_empty()
     }
 }
 
@@ -1386,18 +1393,9 @@ impl Focusable for Sidebar {
 
 impl Render for Sidebar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let titlebar_height = ui::utils::platform_title_bar_height(window);
         let ui_font = theme::setup_ui_font(window, cx);
-        let is_focused = self.focus_handle.is_focused(window)
-            || self.filter_editor.focus_handle(cx).is_focused(window);
         let has_query = self.has_filter_query(cx);
 
-        let focus_tooltip_label = if is_focused {
-            "Focus Workspace"
-        } else {
-            "Focus Sidebar"
-        };
-
         v_flex()
             .id("workspace-sidebar")
             .key_context("WorkspaceSidebar")
@@ -1413,69 +1411,26 @@ impl Render for Sidebar {
             .on_action(cx.listener(Self::collapse_selected_entry))
             .on_action(cx.listener(Self::cancel))
             .font(ui_font)
-            .h_full()
-            .w(self.width)
+            .size_full()
             .bg(cx.theme().colors().surface_background)
-            .border_r_1()
-            .border_color(cx.theme().colors().border)
-            .child(
-                h_flex()
-                    .flex_none()
-                    .h(titlebar_height)
-                    .w_full()
-                    .mt_px()
-                    .pb_px()
-                    .pr_1()
-                    .when_else(
-                        cfg!(target_os = "macos") && !window.is_fullscreen(),
-                        |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
-                        |this| this.pl_2(),
-                    )
-                    .justify_between()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .child({
-                        let focus_handle_toggle = self.focus_handle.clone();
-                        let focus_handle_focus = self.focus_handle.clone();
-                        IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::element(move |_, cx| {
-                                v_flex()
-                                    .gap_1()
-                                    .child(
-                                        h_flex()
-                                            .gap_2()
-                                            .justify_between()
-                                            .child(Label::new("Close Sidebar"))
-                                            .child(KeyBinding::for_action_in(
-                                                &ToggleWorkspaceSidebar,
-                                                &focus_handle_toggle,
-                                                cx,
-                                            )),
-                                    )
-                                    .child(
-                                        h_flex()
-                                            .pt_1()
-                                            .gap_2()
-                                            .border_t_1()
-                                            .border_color(cx.theme().colors().border_variant)
-                                            .justify_between()
-                                            .child(Label::new(focus_tooltip_label))
-                                            .child(KeyBinding::for_action_in(
-                                                &FocusWorkspaceSidebar,
-                                                &focus_handle_focus,
-                                                cx,
-                                            )),
-                                    )
-                                    .into_any_element()
-                            }))
-                            .on_click(cx.listener(|_this, _, _window, cx| {
-                                cx.emit(SidebarEvent::Close);
-                            }))
-                    })
-                    .child(self.render_recent_projects_button(cx)),
-            )
-            .child(
+            .child({
+                let docked_right =
+                    AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
+                let render_close_button = || {
+                    IconButton::new("sidebar-close-toggle", IconName::WorkspaceNavOpen)
+                        .icon_size(IconSize::Small)
+                        .tooltip(move |_, cx| {
+                            Tooltip::for_action(
+                                "Close Threads Sidebar",
+                                &ToggleWorkspaceSidebar,
+                                cx,
+                            )
+                        })
+                        .on_click(|_, window, cx| {
+                            window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+                        })
+                };
+
                 h_flex()
                     .flex_none()
                     .px_2p5()
@@ -1483,6 +1438,7 @@ impl Render for Sidebar {
                     .gap_2()
                     .border_b_1()
                     .border_color(cx.theme().colors().border)
+                    .when(!docked_right, |this| this.child(render_close_button()))
                     .child(
                         Icon::new(IconName::MagnifyingGlass)
                             .size(IconSize::Small)
@@ -1499,8 +1455,9 @@ impl Render for Sidebar {
                                     this.update_entries(cx);
                                 })),
                         )
-                    }),
-            )
+                    })
+                    .when(docked_right, |this| this.child(render_close_button()))
+            })
             .child(
                 v_flex()
                     .flex_1()
@@ -1521,26 +1478,24 @@ impl Render for Sidebar {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
     use acp_thread::StubAgentConnection;
     use agent::ThreadStore;
-    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
     use assistant_text_thread::TextThreadStore;
     use chrono::DateTime;
     use feature_flags::FeatureFlagAppExt as _;
     use fs::FakeFs;
     use gpui::TestAppContext;
-    use settings::SettingsStore;
     use std::sync::Arc;
     use util::path_list::PathList;
 
     fn init_test(cx: &mut TestAppContext) {
+        crate::test_support::init_test(cx);
         cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-            theme::init(theme::LoadThemes::JustBase, cx);
-            editor::init(cx);
             cx.update_flags(false, vec!["agent-v2".into()]);
             ThreadStore::init_global(cx);
+            language_model::LanguageModelRegistry::test(cx);
+            prompt_store::init(cx);
         });
     }
 
@@ -1581,14 +1536,33 @@ mod tests {
         multi_workspace: &Entity<MultiWorkspace>,
         cx: &mut gpui::VisualTestContext,
     ) -> Entity<Sidebar> {
-        let multi_workspace = multi_workspace.clone();
-        let sidebar =
-            cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.register_sidebar(sidebar.clone(), window, cx);
+        let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
+        sidebar
+    }
+
+    fn setup_sidebar_with_agent_panel(
+        multi_workspace: &Entity<MultiWorkspace>,
+        cx: &mut gpui::VisualTestContext,
+    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
+        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+        let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
+        let panel = add_agent_panel(&workspace, &project, cx);
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.right_dock().update(cx, |dock, cx| {
+                if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
+                    dock.activate_panel(panel_ix, window, cx);
+                }
+                dock.set_open(true, window, cx);
+            });
         });
         cx.run_until_parked();
-        sidebar
+        let sidebar = panel.read_with(cx, |panel, _cx| {
+            panel
+                .sidebar
+                .clone()
+                .expect("AgentPanel should have created a sidebar")
+        });
+        (sidebar, panel)
     }
 
     async fn save_n_test_threads(
@@ -1635,16 +1609,10 @@ mod tests {
         cx.run_until_parked();
     }
 
-    fn open_and_focus_sidebar(
-        sidebar: &Entity<Sidebar>,
-        multi_workspace: &Entity<MultiWorkspace>,
-        cx: &mut gpui::VisualTestContext,
-    ) {
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.toggle_sidebar(window, cx);
-        });
+    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
         cx.run_until_parked();
-        sidebar.update_in(cx, |_, window, cx| {
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.set_open(true, cx);
             cx.focus_self(window);
         });
         cx.run_until_parked();
@@ -1898,7 +1866,7 @@ mod tests {
         assert!(entries.iter().any(|e| e.contains("View More (12)")));
 
         // Focus and navigate to View More, then confirm to expand by one batch
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         for _ in 0..7 {
             cx.dispatch_action(SelectNext);
         }
@@ -2033,6 +2001,7 @@ mod tests {
                     is_live: false,
                     is_background: false,
                     highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
                 }),
                 // Active thread with Running status
                 ListEntry::Thread(ThreadEntry {
@@ -2051,6 +2020,7 @@ mod tests {
                     is_live: true,
                     is_background: false,
                     highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
                 }),
                 // Active thread with Error status
                 ListEntry::Thread(ThreadEntry {
@@ -2069,6 +2039,7 @@ mod tests {
                     is_live: true,
                     is_background: false,
                     highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
                 }),
                 // Thread with WaitingForConfirmation status, not active
                 ListEntry::Thread(ThreadEntry {
@@ -2087,6 +2058,7 @@ mod tests {
                     is_live: false,
                     is_background: false,
                     highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
                 }),
                 // Background thread that completed (should show notification)
                 ListEntry::Thread(ThreadEntry {
@@ -2105,6 +2077,7 @@ mod tests {
                     is_live: true,
                     is_background: true,
                     highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
                 }),
                 // View More entry
                 ListEntry::ViewMore {
@@ -2181,7 +2154,7 @@ mod tests {
         // Entries: [header, thread3, thread2, thread1]
         // Focusing the sidebar does not set a selection; select_next/select_previous
         // handle None gracefully by starting from the first or last entry.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 
         // First SelectNext from None starts at index 0
@@ -2230,7 +2203,7 @@ mod tests {
         multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
         cx.run_until_parked();
 
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
 
         // SelectLast jumps to the end
         cx.dispatch_action(SelectLast);
@@ -2253,7 +2226,7 @@ mod tests {
 
         // Open the sidebar so it's rendered, then focus it to trigger focus_in.
         // focus_in no longer sets a default selection.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 
         // Manually set a selection, blur, then refocus — selection should be preserved
@@ -2285,6 +2258,9 @@ mod tests {
         });
         cx.run_until_parked();
 
+        // Add an agent panel to workspace 1 so the sidebar renders when it's active.
+        setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
         let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
         save_n_test_threads(1, &path_list, cx).await;
         multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@@ -2311,7 +2287,7 @@ mod tests {
         );
 
         // Focus the sidebar and manually select the header (index 0)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         sidebar.update_in(cx, |sidebar, _window, _cx| {
             sidebar.selection = Some(0);
         });
@@ -2354,7 +2330,7 @@ mod tests {
         assert!(entries.iter().any(|e| e.contains("View More (3)")));
 
         // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         for _ in 0..7 {
             cx.dispatch_action(SelectNext);
         }
@@ -2389,7 +2365,7 @@ mod tests {
         );
 
         // Focus sidebar and manually select the header (index 0). Press left to collapse.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         sidebar.update_in(cx, |sidebar, _window, _cx| {
             sidebar.selection = Some(0);
         });
@@ -2429,7 +2405,7 @@ mod tests {
         cx.run_until_parked();
 
         // Focus sidebar (selection starts at None), then navigate down to the thread (child)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         cx.dispatch_action(SelectNext);
         cx.dispatch_action(SelectNext);
         assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
@@ -2464,7 +2440,7 @@ mod tests {
         );
 
         // Focus sidebar — focus_in does not set a selection
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
 
         // First SelectNext from None starts at index 0 (header)
@@ -2497,7 +2473,7 @@ mod tests {
         cx.run_until_parked();
 
         // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         cx.dispatch_action(SelectNext);
         cx.dispatch_action(SelectNext);
         assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
@@ -2517,24 +2493,6 @@ mod tests {
         );
     }
 
-    async fn init_test_project_with_agent_panel(
-        worktree_path: &str,
-        cx: &mut TestAppContext,
-    ) -> Entity<project::Project> {
-        agent_ui::test_support::init_test(cx);
-        cx.update(|cx| {
-            cx.update_flags(false, vec!["agent-v2".into()]);
-            ThreadStore::init_global(cx);
-            language_model::LanguageModelRegistry::test(cx);
-        });
-
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
-            .await;
-        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-        project::Project::test(fs, [worktree_path.as_ref()], cx).await
-    }
-
     fn add_agent_panel(
         workspace: &Entity<Workspace>,
         project: &Entity<project::Project>,
@@ -2548,36 +2506,25 @@ mod tests {
         })
     }
 
-    fn setup_sidebar_with_agent_panel(
-        multi_workspace: &Entity<MultiWorkspace>,
-        project: &Entity<project::Project>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
-        let sidebar = setup_sidebar(multi_workspace, cx);
-        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
-        let panel = add_agent_panel(&workspace, project, cx);
-        (sidebar, panel)
-    }
-
     #[gpui::test]
     async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
-        let project = init_test_project_with_agent_panel("/my-project", cx).await;
+        let project = init_test_project("/my-project", cx).await;
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
         let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 
         // Open thread A and keep it generating.
-        let connection_a = StubAgentConnection::new();
-        open_thread_with_connection(&panel, connection_a.clone(), cx);
+        let connection = StubAgentConnection::new();
+        open_thread_with_connection(&panel, connection.clone(), cx);
         send_message(&panel, cx);
 
         let session_id_a = active_session_id(&panel, cx);
         save_thread_to_store(&session_id_a, &path_list, cx).await;
 
         cx.update(|_, cx| {
-            connection_a.send_update(
+            connection.send_update(
                 session_id_a.clone(),
                 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
                 cx,
@@ -2586,11 +2533,10 @@ mod tests {
         cx.run_until_parked();
 
         // Open thread B (idle, default response) — thread A goes to background.
-        let connection_b = StubAgentConnection::new();
-        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
             acp::ContentChunk::new("Done".into()),
         )]);
-        open_thread_with_connection(&panel, connection_b, cx);
+        open_thread_with_connection(&panel, connection, cx);
         send_message(&panel, cx);
 
         let session_id_b = active_session_id(&panel, cx);
@@ -2608,10 +2554,10 @@ mod tests {
 
     #[gpui::test]
     async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
-        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+        let project_a = init_test_project("/project-a", cx).await;
         let (multi_workspace, cx) = cx
             .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
-        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
+        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
         let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
 
@@ -2815,7 +2761,7 @@ mod tests {
         );
 
         // User types a search query to filter down.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         type_in_search(&sidebar, "alpha", cx);
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
@@ -3138,7 +3084,7 @@ mod tests {
 
         // User focuses the sidebar and collapses the group using keyboard:
         // manually select the header, then press CollapseSelectedEntry to collapse.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
         sidebar.update_in(cx, |sidebar, _window, _cx| {
             sidebar.selection = Some(0);
         });
@@ -3188,7 +3134,7 @@ mod tests {
         }
         cx.run_until_parked();
 
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+        open_and_focus_sidebar(&sidebar, cx);
 
         // User types "fix" — two threads match.
         type_in_search(&sidebar, "fix", cx);
@@ -3365,10 +3311,10 @@ mod tests {
 
     #[gpui::test]
     async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
-        let project = init_test_project_with_agent_panel("/my-project", cx).await;
+        let project = init_test_project("/my-project", cx).await;
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
         let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 
@@ -3413,10 +3359,10 @@ mod tests {
 
     #[gpui::test]
     async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
-        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+        let project_a = init_test_project("/project-a", cx).await;
         let (multi_workspace, cx) = cx
             .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
-        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
+        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
 
         let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
 
@@ -3445,7 +3391,8 @@ mod tests {
         let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
 
         // ── 1. Initial state: no focused thread ──────────────────────────────
-        // Workspace B is active (just added), so its header is the active entry.
+        // Workspace B is active (just added) and has no thread, so its header
+        // is the active entry.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
                 sidebar.focused_thread, None,
@@ -3460,6 +3407,7 @@ mod tests {
             );
         });
 
+        // ── 2. Click thread in workspace A via sidebar ───────────────────────
         sidebar.update_in(cx, |sidebar, window, cx| {
             sidebar.activate_thread(
                 acp_thread::AgentSessionInfo {
@@ -3503,6 +3451,7 @@ mod tests {
             );
         });
 
+        // ── 3. Open thread in workspace B, then click it via sidebar ─────────
         let connection_b = StubAgentConnection::new();
         connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
             acp::ContentChunk::new("Thread B".into()),
@@ -3514,6 +3463,16 @@ mod tests {
         save_thread_to_store(&session_id_b, &path_list_b, cx).await;
         cx.run_until_parked();
 
+        // Opening a thread in a non-active workspace should NOT change
+        // focused_thread — it's derived from the active workspace.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Opening a thread in a non-active workspace should not affect focused_thread"
+            );
+        });
+
         // Workspace A is currently active. Click a thread in workspace B,
         // which also triggers a workspace switch.
         sidebar.update_in(cx, |sidebar, window, cx| {
@@ -3548,25 +3507,30 @@ mod tests {
             );
         });
 
+        // ── 4. Switch workspace → focused_thread reflects new workspace ──────
         multi_workspace.update_in(cx, |mw, window, cx| {
             mw.activate_next_workspace(window, cx);
         });
         cx.run_until_parked();
 
+        // Workspace A is now active. Its agent panel still has session_id_a
+        // loaded, so focused_thread should reflect that.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread, None,
-                "External workspace switch should clear focused_thread"
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Switching workspaces should derive focused_thread from the new active workspace"
             );
             let active_entry = sidebar
                 .active_entry_index
                 .and_then(|ix| sidebar.contents.entries.get(ix));
             assert!(
-                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
-                "Active entry should be the workspace header after external switch"
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
+                "Active entry should be workspace_a's active thread"
             );
         });
 
+        // ── 5. Opening a thread in a non-active workspace is ignored ──────────
         let connection_b2 = StubAgentConnection::new();
         connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
             acp::ContentChunk::new("New thread".into()),
@@ -3577,69 +3541,48 @@ mod tests {
         save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
         cx.run_until_parked();
 
+        // Workspace A is still active, so focused_thread stays on session_id_a.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
                 sidebar.focused_thread.as_ref(),
-                Some(&session_id_b2),
-                "Opening a thread externally should set focused_thread"
-            );
-        });
-
-        workspace_b.update_in(cx, |workspace, window, cx| {
-            workspace.focus_handle(cx).focus(window, cx);
-        });
-        cx.run_until_parked();
-
-        sidebar.read_with(cx, |sidebar, _cx| {
-            assert_eq!(
-                sidebar.focused_thread.as_ref(),
-                Some(&session_id_b2),
-                "Defocusing the sidebar should not clear focused_thread"
+                Some(&session_id_a),
+                "Opening a thread in a non-active workspace should not affect focused_thread"
             );
         });
 
+        // ── 6. Activating workspace B shows its active thread ────────────────
         sidebar.update_in(cx, |sidebar, window, cx| {
             sidebar.activate_workspace(&workspace_b, window, cx);
         });
         cx.run_until_parked();
 
+        // Workspace B is now active with session_id_b2 loaded.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread, None,
-                "Clicking a workspace header should clear focused_thread"
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_b2),
+                "Activating workspace_b should show workspace_b's active thread"
             );
             let active_entry = sidebar
                 .active_entry_index
                 .and_then(|ix| sidebar.contents.entries.get(ix));
             assert!(
-                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
-                "Active entry should be the workspace header"
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
+                "Active entry should be workspace_b's active thread"
             );
         });
 
-        // ── 8. Focusing the agent panel thread restores focused_thread ────
-        // Workspace B still has session_id_b2 loaded in the agent panel.
-        // Clicking into the thread (simulated by focusing its view) should
-        // set focused_thread via the ThreadFocused event.
-        panel_b.update_in(cx, |panel, window, cx| {
-            if let Some(thread_view) = panel.active_connection_view() {
-                thread_view.read(cx).focus_handle(cx).focus(window, cx);
-            }
+        // ── 7. Switching back to workspace A reflects its thread ─────────────
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_next_workspace(window, cx);
         });
         cx.run_until_parked();
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
                 sidebar.focused_thread.as_ref(),
-                Some(&session_id_b2),
-                "Focusing the agent panel thread should set focused_thread"
-            );
-            let active_entry = sidebar
-                .active_entry_index
-                .and_then(|ix| sidebar.contents.entries.get(ix));
-            assert!(
-                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
-                "Active entry should be the focused thread"
+                Some(&session_id_a),
+                "Switching back to workspace_a should show its active thread"
             );
         });
     }

crates/agent_ui/src/thread_history.rs 🔗

@@ -1,118 +1,21 @@
-use crate::ConnectionView;
-use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
 use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate};
 use agent_client_protocol as acp;
-use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
-use editor::{Editor, EditorEvent};
-use fuzzy::StringMatchCandidate;
-use gpui::{
-    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
-    UniformListScrollHandle, WeakEntity, Window, uniform_list,
-};
-use std::{fmt::Display, ops::Range, rc::Rc};
-use text::Bias;
-use time::{OffsetDateTime, UtcOffset};
-use ui::{
-    ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
-    WithScrollbar, prelude::*,
-};
-
-const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
-
-fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
-    entry
-        .title
-        .as_ref()
-        .filter(|title| !title.is_empty())
-        .unwrap_or(DEFAULT_TITLE)
-}
+use gpui::{App, Task};
+use std::rc::Rc;
+use ui::prelude::*;
 
 pub struct ThreadHistory {
     session_list: Option<Rc<dyn AgentSessionList>>,
     sessions: Vec<AgentSessionInfo>,
-    scroll_handle: UniformListScrollHandle,
-    selected_index: usize,
-    hovered_index: Option<usize>,
-    search_editor: Entity<Editor>,
-    search_query: SharedString,
-    visible_items: Vec<ListItemType>,
-    local_timezone: UtcOffset,
-    confirming_delete_history: bool,
-    _visible_items_task: Task<()>,
     _refresh_task: Task<()>,
     _watch_task: Option<Task<()>>,
-    _subscriptions: Vec<gpui::Subscription>,
-}
-
-enum ListItemType {
-    BucketSeparator(TimeBucket),
-    Entry {
-        entry: AgentSessionInfo,
-        format: EntryTimeFormat,
-    },
-    SearchResult {
-        entry: AgentSessionInfo,
-        positions: Vec<usize>,
-    },
-}
-
-impl ListItemType {
-    fn history_entry(&self) -> Option<&AgentSessionInfo> {
-        match self {
-            ListItemType::Entry { entry, .. } => Some(entry),
-            ListItemType::SearchResult { entry, .. } => Some(entry),
-            _ => None,
-        }
-    }
 }
 
-pub enum ThreadHistoryEvent {
-    Open(AgentSessionInfo),
-}
-
-impl EventEmitter<ThreadHistoryEvent> for ThreadHistory {}
-
 impl ThreadHistory {
-    pub fn new(
-        session_list: Option<Rc<dyn AgentSessionList>>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let search_editor = cx.new(|cx| {
-            let mut editor = Editor::single_line(window, cx);
-            editor.set_placeholder_text("Search threads...", window, cx);
-            editor
-        });
-
-        let search_editor_subscription =
-            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
-                if let EditorEvent::BufferEdited = event {
-                    let query = search_editor.read(cx).text(cx);
-                    if this.search_query != query {
-                        this.search_query = query.into();
-                        this.update_visible_items(false, cx);
-                    }
-                }
-            });
-
-        let scroll_handle = UniformListScrollHandle::default();
-
+    pub fn new(session_list: Option<Rc<dyn AgentSessionList>>, cx: &mut Context<Self>) -> Self {
         let mut this = Self {
             session_list: None,
             sessions: Vec::new(),
-            scroll_handle,
-            selected_index: 0,
-            hovered_index: None,
-            visible_items: Default::default(),
-            search_editor,
-            local_timezone: UtcOffset::from_whole_seconds(
-                chrono::Local::now().offset().local_minus_utc(),
-            )
-            .unwrap(),
-            search_query: SharedString::default(),
-            confirming_delete_history: false,
-            _subscriptions: vec![search_editor_subscription],
-            _visible_items_task: Task::ready(()),
             _refresh_task: Task::ready(()),
             _watch_task: None,
         };
@@ -120,43 +23,6 @@ impl ThreadHistory {
         this
     }
 
-    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
-        let entries = self.sessions.clone();
-        let new_list_items = if self.search_query.is_empty() {
-            self.add_list_separators(entries, cx)
-        } else {
-            self.filter_search_results(entries, cx)
-        };
-        let selected_history_entry = if preserve_selected_item {
-            self.selected_history_entry().cloned()
-        } else {
-            None
-        };
-
-        self._visible_items_task = cx.spawn(async move |this, cx| {
-            let new_visible_items = new_list_items.await;
-            this.update(cx, |this, cx| {
-                let new_selected_index = if let Some(history_entry) = selected_history_entry {
-                    new_visible_items
-                        .iter()
-                        .position(|visible_entry| {
-                            visible_entry
-                                .history_entry()
-                                .is_some_and(|entry| entry.session_id == history_entry.session_id)
-                        })
-                        .unwrap_or(0)
-                } else {
-                    0
-                };
-
-                this.visible_items = new_visible_items;
-                this.set_selected_index(new_selected_index, Bias::Right, cx);
-                cx.notify();
-            })
-            .ok();
-        });
-    }
-
     pub fn set_session_list(
         &mut self,
         session_list: Option<Rc<dyn AgentSessionList>>,
@@ -170,9 +36,6 @@ impl ThreadHistory {
 
         self.session_list = session_list;
         self.sessions.clear();
-        self.visible_items.clear();
-        self.selected_index = 0;
-        self._visible_items_task = Task::ready(());
         self._refresh_task = Task::ready(());
 
         let Some(session_list) = self.session_list.as_ref() else {
@@ -181,9 +44,8 @@ impl ThreadHistory {
             return;
         };
         let Some(rx) = session_list.watch(cx) else {
-            // No watch support - do a one-time refresh
             self._watch_task = None;
-            self.refresh_sessions(false, false, cx);
+            self.refresh_sessions(false, cx);
             return;
         };
         session_list.notify_refresh();
@@ -191,7 +53,6 @@ impl ThreadHistory {
         self._watch_task = Some(cx.spawn(async move |this, cx| {
             while let Ok(first_update) = rx.recv().await {
                 let mut updates = vec![first_update];
-                // Collect any additional updates that are already in the channel
                 while let Ok(update) = rx.try_recv() {
                     updates.push(update);
                 }
@@ -202,7 +63,7 @@ impl ThreadHistory {
                         .any(|u| matches!(u, SessionListUpdate::Refresh));
 
                     if needs_refresh {
-                        this.refresh_sessions(true, false, cx);
+                        this.refresh_sessions(false, cx);
                     } else {
                         for update in updates {
                             if let SessionListUpdate::SessionInfo { session_id, update } = update {
@@ -217,7 +78,7 @@ impl ThreadHistory {
     }
 
     pub(crate) fn refresh_full_history(&mut self, cx: &mut Context<Self>) {
-        self.refresh_sessions(true, true, cx);
+        self.refresh_sessions(true, cx);
     }
 
     fn apply_info_update(
@@ -258,23 +119,15 @@ impl ThreadHistory {
             session.meta = Some(meta);
         }
 
-        self.update_visible_items(true, cx);
+        cx.notify();
     }
 
-    fn refresh_sessions(
-        &mut self,
-        preserve_selected_item: bool,
-        load_all_pages: bool,
-        cx: &mut Context<Self>,
-    ) {
+    fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context<Self>) {
         let Some(session_list) = self.session_list.clone() else {
-            self.update_visible_items(preserve_selected_item, cx);
+            cx.notify();
             return;
         };
 
-        // If a new refresh arrives while pagination is in progress, the previous
-        // `_refresh_task` is cancelled. This is intentional (latest refresh wins),
-        // but means sessions may be in a partial state until the new refresh completes.
         self._refresh_task = cx.spawn(async move |this, cx| {
             let mut cursor: Option<String> = None;
             let mut is_first_page = true;
@@ -305,7 +158,7 @@ impl ThreadHistory {
                     } else {
                         this.sessions.extend(page_sessions);
                     }
-                    this.update_visible_items(preserve_selected_item, cx);
+                    cx.notify();
                 })
                 .ok();
 
@@ -378,693 +231,11 @@ impl ThreadHistory {
         }
     }
 
-    fn add_list_separators(
-        &self,
-        entries: Vec<AgentSessionInfo>,
-        cx: &App,
-    ) -> Task<Vec<ListItemType>> {
-        cx.background_spawn(async move {
-            let mut items = Vec::with_capacity(entries.len() + 1);
-            let mut bucket = None;
-            let today = Local::now().naive_local().date();
-
-            for entry in entries.into_iter() {
-                let entry_bucket = entry
-                    .updated_at
-                    .map(|timestamp| {
-                        let entry_date = timestamp.with_timezone(&Local).naive_local().date();
-                        TimeBucket::from_dates(today, entry_date)
-                    })
-                    .unwrap_or(TimeBucket::All);
-
-                if Some(entry_bucket) != bucket {
-                    bucket = Some(entry_bucket);
-                    items.push(ListItemType::BucketSeparator(entry_bucket));
-                }
-
-                items.push(ListItemType::Entry {
-                    entry,
-                    format: entry_bucket.into(),
-                });
-            }
-            items
-        })
-    }
-
-    fn filter_search_results(
-        &self,
-        entries: Vec<AgentSessionInfo>,
-        cx: &App,
-    ) -> Task<Vec<ListItemType>> {
-        let query = self.search_query.clone();
-        cx.background_spawn({
-            let executor = cx.background_executor().clone();
-            async move {
-                let mut candidates = Vec::with_capacity(entries.len());
-
-                for (idx, entry) in entries.iter().enumerate() {
-                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
-                }
-
-                const MAX_MATCHES: usize = 100;
-
-                let matches = fuzzy::match_strings(
-                    &candidates,
-                    &query,
-                    false,
-                    true,
-                    MAX_MATCHES,
-                    &Default::default(),
-                    executor,
-                )
-                .await;
-
-                matches
-                    .into_iter()
-                    .map(|search_match| ListItemType::SearchResult {
-                        entry: entries[search_match.candidate_id].clone(),
-                        positions: search_match.positions,
-                    })
-                    .collect()
-            }
-        })
-    }
-
-    fn search_produced_no_matches(&self) -> bool {
-        self.visible_items.is_empty() && !self.search_query.is_empty()
-    }
-
-    fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
-        self.get_history_entry(self.selected_index)
-    }
-
-    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
-        self.visible_items.get(visible_items_ix)?.history_entry()
-    }
-
-    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
-        if self.visible_items.len() == 0 {
-            self.selected_index = 0;
-            return;
-        }
-        while matches!(
-            self.visible_items.get(index),
-            None | Some(ListItemType::BucketSeparator(..))
-        ) {
-            index = match bias {
-                Bias::Left => {
-                    if index == 0 {
-                        self.visible_items.len() - 1
-                    } else {
-                        index - 1
-                    }
-                }
-                Bias::Right => {
-                    if index >= self.visible_items.len() - 1 {
-                        0
-                    } else {
-                        index + 1
-                    }
-                }
-            };
-        }
-        self.selected_index = index;
-        self.scroll_handle
-            .scroll_to_item(index, ScrollStrategy::Top);
-        cx.notify()
-    }
-
-    pub fn select_previous(
-        &mut self,
-        _: &menu::SelectPrevious,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if self.selected_index == 0 {
-            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
-        } else {
-            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
-        }
-    }
-
-    pub fn select_next(
-        &mut self,
-        _: &menu::SelectNext,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if self.selected_index == self.visible_items.len() - 1 {
-            self.set_selected_index(0, Bias::Right, cx);
+    pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task<anyhow::Result<()>> {
+        if let Some(session_list) = self.session_list.as_ref() {
+            session_list.delete_sessions(cx)
         } else {
-            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
-        }
-    }
-
-    fn select_first(
-        &mut self,
-        _: &menu::SelectFirst,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.set_selected_index(0, Bias::Right, cx);
-    }
-
-    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
-        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
-    }
-
-    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
-        self.confirm_entry(self.selected_index, cx);
-    }
-
-    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
-        let Some(entry) = self.get_history_entry(ix) else {
-            return;
-        };
-        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
-    }
-
-    fn remove_selected_thread(
-        &mut self,
-        _: &RemoveSelectedThread,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.remove_thread(self.selected_index, cx)
-    }
-
-    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
-        let Some(entry) = self.get_history_entry(visible_item_ix) else {
-            return;
-        };
-        let Some(session_list) = self.session_list.as_ref() else {
-            return;
-        };
-        if !session_list.supports_delete() {
-            return;
-        }
-        let task = session_list.delete_session(&entry.session_id, cx);
-        task.detach_and_log_err(cx);
-    }
-
-    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
-        let Some(session_list) = self.session_list.as_ref() else {
-            return;
-        };
-        if !session_list.supports_delete() {
-            return;
-        }
-        session_list.delete_sessions(cx).detach_and_log_err(cx);
-        self.confirming_delete_history = false;
-        cx.notify();
-    }
-
-    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
-        self.confirming_delete_history = true;
-        cx.notify();
-    }
-
-    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
-        self.confirming_delete_history = false;
-        cx.notify();
-    }
-
-    fn render_list_items(
-        &mut self,
-        range: Range<usize>,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Vec<AnyElement> {
-        self.visible_items
-            .get(range.clone())
-            .into_iter()
-            .flatten()
-            .enumerate()
-            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
-            .collect()
-    }
-
-    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
-        match item {
-            ListItemType::Entry { entry, format } => self
-                .render_history_entry(entry, *format, ix, Vec::default(), cx)
-                .into_any(),
-            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
-                entry,
-                EntryTimeFormat::DateAndTime,
-                ix,
-                positions.clone(),
-                cx,
-            ),
-            ListItemType::BucketSeparator(bucket) => div()
-                .px(DynamicSpacing::Base06.rems(cx))
-                .pt_2()
-                .pb_1()
-                .child(
-                    Label::new(bucket.to_string())
-                        .size(LabelSize::XSmall)
-                        .color(Color::Muted),
-                )
-                .into_any_element(),
-        }
-    }
-
-    fn render_history_entry(
-        &self,
-        entry: &AgentSessionInfo,
-        format: EntryTimeFormat,
-        ix: usize,
-        highlight_positions: Vec<usize>,
-        cx: &Context<Self>,
-    ) -> AnyElement {
-        let selected = ix == self.selected_index;
-        let hovered = Some(ix) == self.hovered_index;
-        let entry_time = entry.updated_at;
-        let display_text = match (format, entry_time) {
-            (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
-                let now = Utc::now();
-                let duration = now.signed_duration_since(entry_time);
-                let days = duration.num_days();
-
-                format!("{}d", days)
-            }
-            (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
-                format.format_timestamp(entry_time.timestamp(), self.local_timezone)
-            }
-            (_, None) => "—".to_string(),
-        };
-
-        let title = thread_title(entry).clone();
-        let full_date = entry_time
-            .map(|time| {
-                EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
-            })
-            .unwrap_or_else(|| "Unknown".to_string());
-
-        h_flex()
-            .w_full()
-            .pb_1()
-            .child(
-                ListItem::new(ix)
-                    .rounded()
-                    .toggle_state(selected)
-                    .spacing(ListItemSpacing::Sparse)
-                    .start_slot(
-                        h_flex()
-                            .w_full()
-                            .gap_2()
-                            .justify_between()
-                            .child(
-                                HighlightedLabel::new(thread_title(entry), highlight_positions)
-                                    .size(LabelSize::Small)
-                                    .truncate(),
-                            )
-                            .child(
-                                Label::new(display_text)
-                                    .color(Color::Muted)
-                                    .size(LabelSize::XSmall),
-                            ),
-                    )
-                    .tooltip(move |_, cx| {
-                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
-                    })
-                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
-                        if *is_hovered {
-                            this.hovered_index = Some(ix);
-                        } else if this.hovered_index == Some(ix) {
-                            this.hovered_index = None;
-                        }
-
-                        cx.notify();
-                    }))
-                    .end_slot::<IconButton>(if hovered && self.supports_delete() {
-                        Some(
-                            IconButton::new("delete", IconName::Trash)
-                                .shape(IconButtonShape::Square)
-                                .icon_size(IconSize::XSmall)
-                                .icon_color(Color::Muted)
-                                .tooltip(move |_window, cx| {
-                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
-                                })
-                                .on_click(cx.listener(move |this, _, _, cx| {
-                                    this.remove_thread(ix, cx);
-                                    cx.stop_propagation()
-                                })),
-                        )
-                    } else {
-                        None
-                    })
-                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
-            )
-            .into_any_element()
-    }
-}
-
-impl Focusable for ThreadHistory {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.search_editor.focus_handle(cx)
-    }
-}
-
-impl Render for ThreadHistory {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let has_no_history = self.is_empty();
-
-        v_flex()
-            .key_context("ThreadHistory")
-            .size_full()
-            .bg(cx.theme().colors().panel_background)
-            .on_action(cx.listener(Self::select_previous))
-            .on_action(cx.listener(Self::select_next))
-            .on_action(cx.listener(Self::select_first))
-            .on_action(cx.listener(Self::select_last))
-            .on_action(cx.listener(Self::confirm))
-            .on_action(cx.listener(Self::remove_selected_thread))
-            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
-                this.remove_history(window, cx);
-            }))
-            .child(
-                h_flex()
-                    .h(Tab::container_height(cx))
-                    .w_full()
-                    .py_1()
-                    .px_2()
-                    .gap_2()
-                    .justify_between()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .child(
-                        Icon::new(IconName::MagnifyingGlass)
-                            .color(Color::Muted)
-                            .size(IconSize::Small),
-                    )
-                    .child(self.search_editor.clone()),
-            )
-            .child({
-                let view = v_flex()
-                    .id("list-container")
-                    .relative()
-                    .overflow_hidden()
-                    .flex_grow();
-
-                if has_no_history {
-                    view.justify_center().items_center().child(
-                        Label::new("You don't have any past threads yet.")
-                            .size(LabelSize::Small)
-                            .color(Color::Muted),
-                    )
-                } else if self.search_produced_no_matches() {
-                    view.justify_center()
-                        .items_center()
-                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
-                } else {
-                    view.child(
-                        uniform_list(
-                            "thread-history",
-                            self.visible_items.len(),
-                            cx.processor(|this, range: Range<usize>, window, cx| {
-                                this.render_list_items(range, window, cx)
-                            }),
-                        )
-                        .p_1()
-                        .pr_4()
-                        .track_scroll(&self.scroll_handle)
-                        .flex_grow(),
-                    )
-                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
-                }
-            })
-            .when(!has_no_history && self.supports_delete(), |this| {
-                this.child(
-                    h_flex()
-                        .p_2()
-                        .border_t_1()
-                        .border_color(cx.theme().colors().border_variant)
-                        .when(!self.confirming_delete_history, |this| {
-                            this.child(
-                                Button::new("delete_history", "Delete All History")
-                                    .full_width()
-                                    .style(ButtonStyle::Outlined)
-                                    .label_size(LabelSize::Small)
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.prompt_delete_history(window, cx);
-                                    })),
-                            )
-                        })
-                        .when(self.confirming_delete_history, |this| {
-                            this.w_full()
-                                .gap_2()
-                                .flex_wrap()
-                                .justify_between()
-                                .child(
-                                    h_flex()
-                                        .flex_wrap()
-                                        .gap_1()
-                                        .child(
-                                            Label::new("Delete all threads?")
-                                                .size(LabelSize::Small),
-                                        )
-                                        .child(
-                                            Label::new("You won't be able to recover them later.")
-                                                .size(LabelSize::Small)
-                                                .color(Color::Muted),
-                                        ),
-                                )
-                                .child(
-                                    h_flex()
-                                        .gap_1()
-                                        .child(
-                                            Button::new("cancel_delete", "Cancel")
-                                                .label_size(LabelSize::Small)
-                                                .on_click(cx.listener(|this, _, window, cx| {
-                                                    this.cancel_delete_history(window, cx);
-                                                })),
-                                        )
-                                        .child(
-                                            Button::new("confirm_delete", "Delete")
-                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
-                                                .color(Color::Error)
-                                                .label_size(LabelSize::Small)
-                                                .on_click(cx.listener(|_, _, window, cx| {
-                                                    window.dispatch_action(
-                                                        Box::new(RemoveHistory),
-                                                        cx,
-                                                    );
-                                                })),
-                                        ),
-                                )
-                        }),
-                )
-            })
-    }
-}
-
-#[derive(IntoElement)]
-pub struct HistoryEntryElement {
-    entry: AgentSessionInfo,
-    thread_view: WeakEntity<ConnectionView>,
-    selected: bool,
-    hovered: bool,
-    supports_delete: bool,
-    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
-}
-
-impl HistoryEntryElement {
-    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
-        Self {
-            entry,
-            thread_view,
-            selected: false,
-            hovered: false,
-            supports_delete: false,
-            on_hover: Box::new(|_, _, _| {}),
-        }
-    }
-
-    pub fn supports_delete(mut self, supports_delete: bool) -> Self {
-        self.supports_delete = supports_delete;
-        self
-    }
-
-    pub fn hovered(mut self, hovered: bool) -> Self {
-        self.hovered = hovered;
-        self
-    }
-
-    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
-        self.on_hover = Box::new(on_hover);
-        self
-    }
-}
-
-impl RenderOnce for HistoryEntryElement {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let id = ElementId::Name(self.entry.session_id.0.clone().into());
-        let title = thread_title(&self.entry).clone();
-        let formatted_time = self
-            .entry
-            .updated_at
-            .map(|timestamp| {
-                let now = chrono::Utc::now();
-                let duration = now.signed_duration_since(timestamp);
-
-                if duration.num_days() > 0 {
-                    format!("{}d", duration.num_days())
-                } else if duration.num_hours() > 0 {
-                    format!("{}h ago", duration.num_hours())
-                } else if duration.num_minutes() > 0 {
-                    format!("{}m ago", duration.num_minutes())
-                } else {
-                    "Just now".to_string()
-                }
-            })
-            .unwrap_or_else(|| "Unknown".to_string());
-
-        ListItem::new(id)
-            .rounded()
-            .toggle_state(self.selected)
-            .spacing(ListItemSpacing::Sparse)
-            .start_slot(
-                h_flex()
-                    .w_full()
-                    .gap_2()
-                    .justify_between()
-                    .child(Label::new(title).size(LabelSize::Small).truncate())
-                    .child(
-                        Label::new(formatted_time)
-                            .color(Color::Muted)
-                            .size(LabelSize::XSmall),
-                    ),
-            )
-            .on_hover(self.on_hover)
-            .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
-                Some(
-                    IconButton::new("delete", IconName::Trash)
-                        .shape(IconButtonShape::Square)
-                        .icon_size(IconSize::XSmall)
-                        .icon_color(Color::Muted)
-                        .tooltip(move |_window, cx| {
-                            Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
-                        })
-                        .on_click({
-                            let thread_view = self.thread_view.clone();
-                            let session_id = self.entry.session_id.clone();
-
-                            move |_event, _window, cx| {
-                                if let Some(thread_view) = thread_view.upgrade() {
-                                    thread_view.update(cx, |thread_view, cx| {
-                                        thread_view.delete_history_entry(&session_id, cx);
-                                    });
-                                }
-                            }
-                        }),
-                )
-            } else {
-                None
-            })
-            .on_click({
-                let thread_view = self.thread_view.clone();
-                let entry = self.entry;
-
-                move |_event, window, cx| {
-                    if let Some(workspace) = thread_view
-                        .upgrade()
-                        .and_then(|view| view.read(cx).workspace().upgrade())
-                    {
-                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
-                            panel.update(cx, |panel, cx| {
-                                panel.load_agent_thread(
-                                    entry.session_id.clone(),
-                                    entry.cwd.clone(),
-                                    entry.title.clone(),
-                                    window,
-                                    cx,
-                                );
-                            });
-                        }
-                    }
-                }
-            })
-    }
-}
-
-#[derive(Clone, Copy)]
-pub enum EntryTimeFormat {
-    DateAndTime,
-    TimeOnly,
-}
-
-impl EntryTimeFormat {
-    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
-        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
-
-        match self {
-            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
-                timestamp,
-                OffsetDateTime::now_utc(),
-                timezone,
-                time_format::TimestampFormat::EnhancedAbsolute,
-            ),
-            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
-        }
-    }
-}
-
-impl From<TimeBucket> for EntryTimeFormat {
-    fn from(bucket: TimeBucket) -> Self {
-        match bucket {
-            TimeBucket::Today => EntryTimeFormat::TimeOnly,
-            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
-            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
-            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
-            TimeBucket::All => EntryTimeFormat::DateAndTime,
-        }
-    }
-}
-
-#[derive(PartialEq, Eq, Clone, Copy, Debug)]
-enum TimeBucket {
-    Today,
-    Yesterday,
-    ThisWeek,
-    PastWeek,
-    All,
-}
-
-impl TimeBucket {
-    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
-        if date == reference {
-            return TimeBucket::Today;
-        }
-
-        if date == reference - TimeDelta::days(1) {
-            return TimeBucket::Yesterday;
-        }
-
-        let week = date.iso_week();
-
-        if reference.iso_week() == week {
-            return TimeBucket::ThisWeek;
-        }
-
-        let last_week = (reference - TimeDelta::days(7)).iso_week();
-
-        if week == last_week {
-            return TimeBucket::PastWeek;
-        }
-
-        TimeBucket::All
-    }
-}
-
-impl Display for TimeBucket {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            TimeBucket::Today => write!(f, "Today"),
-            TimeBucket::Yesterday => write!(f, "Yesterday"),
-            TimeBucket::ThisWeek => write!(f, "This Week"),
-            TimeBucket::PastWeek => write!(f, "Past Week"),
-            TimeBucket::All => write!(f, "All"),
+            Task::ready(Ok(()))
         }
     }
 }
@@ -1073,7 +244,6 @@ impl Display for TimeBucket {
 mod tests {
     use super::*;
     use acp_thread::AgentSessionListResponse;
-    use chrono::NaiveDate;
     use gpui::TestAppContext;
     use std::{
         any::Any,
@@ -1246,9 +416,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, _cx| {
@@ -1270,9 +438,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
         session_list.clear_requested_cursors();
 
@@ -1307,9 +473,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1340,9 +504,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1371,9 +533,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, cx| history.refresh_full_history(cx));

crates/agent_ui/src/thread_history_view.rs 🔗

@@ -0,0 +1,878 @@
+use crate::thread_history::ThreadHistory;
+use crate::{AgentPanel, ConnectionView, RemoveHistory, RemoveSelectedThread};
+use acp_thread::AgentSessionInfo;
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
+use editor::{Editor, EditorEvent};
+use fuzzy::StringMatchCandidate;
+use gpui::{
+    AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
+    UniformListScrollHandle, WeakEntity, Window, uniform_list,
+};
+use std::{fmt::Display, ops::Range};
+use text::Bias;
+use time::{OffsetDateTime, UtcOffset};
+use ui::{
+    ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
+    WithScrollbar, prelude::*,
+};
+
+const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
+
+pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
+    entry
+        .title
+        .as_ref()
+        .filter(|title| !title.is_empty())
+        .unwrap_or(DEFAULT_TITLE)
+}
+
+pub struct ThreadHistoryView {
+    history: Entity<ThreadHistory>,
+    scroll_handle: UniformListScrollHandle,
+    selected_index: usize,
+    hovered_index: Option<usize>,
+    search_editor: Entity<Editor>,
+    search_query: SharedString,
+    visible_items: Vec<ListItemType>,
+    local_timezone: UtcOffset,
+    confirming_delete_history: bool,
+    _visible_items_task: Task<()>,
+    _subscriptions: Vec<gpui::Subscription>,
+}
+
+enum ListItemType {
+    BucketSeparator(TimeBucket),
+    Entry {
+        entry: AgentSessionInfo,
+        format: EntryTimeFormat,
+    },
+    SearchResult {
+        entry: AgentSessionInfo,
+        positions: Vec<usize>,
+    },
+}
+
+impl ListItemType {
+    fn history_entry(&self) -> Option<&AgentSessionInfo> {
+        match self {
+            ListItemType::Entry { entry, .. } => Some(entry),
+            ListItemType::SearchResult { entry, .. } => Some(entry),
+            _ => None,
+        }
+    }
+}
+
+pub enum ThreadHistoryViewEvent {
+    Open(AgentSessionInfo),
+}
+
+impl EventEmitter<ThreadHistoryViewEvent> for ThreadHistoryView {}
+
+impl ThreadHistoryView {
+    pub fn new(
+        history: Entity<ThreadHistory>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let search_editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_placeholder_text("Search threads...", window, cx);
+            editor
+        });
+
+        let search_editor_subscription =
+            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
+                if let EditorEvent::BufferEdited = event {
+                    let query = search_editor.read(cx).text(cx);
+                    if this.search_query != query {
+                        this.search_query = query.into();
+                        this.update_visible_items(false, cx);
+                    }
+                }
+            });
+
+        let history_subscription = cx.observe(&history, |this, _, cx| {
+            this.update_visible_items(true, cx);
+        });
+
+        let scroll_handle = UniformListScrollHandle::default();
+
+        let mut this = Self {
+            history,
+            scroll_handle,
+            selected_index: 0,
+            hovered_index: None,
+            visible_items: Default::default(),
+            search_editor,
+            local_timezone: UtcOffset::from_whole_seconds(
+                chrono::Local::now().offset().local_minus_utc(),
+            )
+            .unwrap(),
+            search_query: SharedString::default(),
+            confirming_delete_history: false,
+            _subscriptions: vec![search_editor_subscription, history_subscription],
+            _visible_items_task: Task::ready(()),
+        };
+        this.update_visible_items(false, cx);
+        this
+    }
+
+    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
+        let entries = self.history.read(cx).sessions().to_vec();
+        let new_list_items = if self.search_query.is_empty() {
+            self.add_list_separators(entries, cx)
+        } else {
+            self.filter_search_results(entries, cx)
+        };
+        let selected_history_entry = if preserve_selected_item {
+            self.selected_history_entry().cloned()
+        } else {
+            None
+        };
+
+        self._visible_items_task = cx.spawn(async move |this, cx| {
+            let new_visible_items = new_list_items.await;
+            this.update(cx, |this, cx| {
+                let new_selected_index = if let Some(history_entry) = selected_history_entry {
+                    new_visible_items
+                        .iter()
+                        .position(|visible_entry| {
+                            visible_entry
+                                .history_entry()
+                                .is_some_and(|entry| entry.session_id == history_entry.session_id)
+                        })
+                        .unwrap_or(0)
+                } else {
+                    0
+                };
+
+                this.visible_items = new_visible_items;
+                this.set_selected_index(new_selected_index, Bias::Right, cx);
+                cx.notify();
+            })
+            .ok();
+        });
+    }
+
+    fn add_list_separators(
+        &self,
+        entries: Vec<AgentSessionInfo>,
+        cx: &App,
+    ) -> Task<Vec<ListItemType>> {
+        cx.background_spawn(async move {
+            let mut items = Vec::with_capacity(entries.len() + 1);
+            let mut bucket = None;
+            let today = Local::now().naive_local().date();
+
+            for entry in entries.into_iter() {
+                let entry_bucket = entry
+                    .updated_at
+                    .map(|timestamp| {
+                        let entry_date = timestamp.with_timezone(&Local).naive_local().date();
+                        TimeBucket::from_dates(today, entry_date)
+                    })
+                    .unwrap_or(TimeBucket::All);
+
+                if Some(entry_bucket) != bucket {
+                    bucket = Some(entry_bucket);
+                    items.push(ListItemType::BucketSeparator(entry_bucket));
+                }
+
+                items.push(ListItemType::Entry {
+                    entry,
+                    format: entry_bucket.into(),
+                });
+            }
+            items
+        })
+    }
+
+    fn filter_search_results(
+        &self,
+        entries: Vec<AgentSessionInfo>,
+        cx: &App,
+    ) -> Task<Vec<ListItemType>> {
+        let query = self.search_query.clone();
+        cx.background_spawn({
+            let executor = cx.background_executor().clone();
+            async move {
+                let mut candidates = Vec::with_capacity(entries.len());
+
+                for (idx, entry) in entries.iter().enumerate() {
+                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
+                }
+
+                const MAX_MATCHES: usize = 100;
+
+                let matches = fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    true,
+                    MAX_MATCHES,
+                    &Default::default(),
+                    executor,
+                )
+                .await;
+
+                matches
+                    .into_iter()
+                    .map(|search_match| ListItemType::SearchResult {
+                        entry: entries[search_match.candidate_id].clone(),
+                        positions: search_match.positions,
+                    })
+                    .collect()
+            }
+        })
+    }
+
+    fn search_produced_no_matches(&self) -> bool {
+        self.visible_items.is_empty() && !self.search_query.is_empty()
+    }
+
+    fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
+        self.get_history_entry(self.selected_index)
+    }
+
+    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
+        self.visible_items.get(visible_items_ix)?.history_entry()
+    }
+
+    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
+        if self.visible_items.len() == 0 {
+            self.selected_index = 0;
+            return;
+        }
+        while matches!(
+            self.visible_items.get(index),
+            None | Some(ListItemType::BucketSeparator(..))
+        ) {
+            index = match bias {
+                Bias::Left => {
+                    if index == 0 {
+                        self.visible_items.len() - 1
+                    } else {
+                        index - 1
+                    }
+                }
+                Bias::Right => {
+                    if index >= self.visible_items.len() - 1 {
+                        0
+                    } else {
+                        index + 1
+                    }
+                }
+            };
+        }
+        self.selected_index = index;
+        self.scroll_handle
+            .scroll_to_item(index, ScrollStrategy::Top);
+        cx.notify()
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.selected_index == 0 {
+            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+        } else {
+            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
+        }
+    }
+
+    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+        if self.selected_index == self.visible_items.len() - 1 {
+            self.set_selected_index(0, Bias::Right, cx);
+        } else {
+            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
+        }
+    }
+
+    fn select_first(
+        &mut self,
+        _: &menu::SelectFirst,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.set_selected_index(0, Bias::Right, cx);
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+        self.confirm_entry(self.selected_index, cx);
+    }
+
+    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+        let Some(entry) = self.get_history_entry(ix) else {
+            return;
+        };
+        cx.emit(ThreadHistoryViewEvent::Open(entry.clone()));
+    }
+
+    fn remove_selected_thread(
+        &mut self,
+        _: &RemoveSelectedThread,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.remove_thread(self.selected_index, cx)
+    }
+
+    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
+        let Some(entry) = self.get_history_entry(visible_item_ix) else {
+            return;
+        };
+        if !self.history.read(cx).supports_delete() {
+            return;
+        }
+        let session_id = entry.session_id.clone();
+        self.history.update(cx, |history, cx| {
+            history
+                .delete_session(&session_id, cx)
+                .detach_and_log_err(cx);
+        });
+    }
+
+    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        if !self.history.read(cx).supports_delete() {
+            return;
+        }
+        self.history.update(cx, |history, cx| {
+            history.delete_sessions(cx).detach_and_log_err(cx);
+        });
+        self.confirming_delete_history = false;
+        cx.notify();
+    }
+
+    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.confirming_delete_history = true;
+        cx.notify();
+    }
+
+    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.confirming_delete_history = false;
+        cx.notify();
+    }
+
+    fn render_list_items(
+        &mut self,
+        range: Range<usize>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<AnyElement> {
+        self.visible_items
+            .get(range.clone())
+            .into_iter()
+            .flatten()
+            .enumerate()
+            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
+            .collect()
+    }
+
+    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
+        match item {
+            ListItemType::Entry { entry, format } => self
+                .render_history_entry(entry, *format, ix, Vec::default(), cx)
+                .into_any(),
+            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
+                entry,
+                EntryTimeFormat::DateAndTime,
+                ix,
+                positions.clone(),
+                cx,
+            ),
+            ListItemType::BucketSeparator(bucket) => div()
+                .px(DynamicSpacing::Base06.rems(cx))
+                .pt_2()
+                .pb_1()
+                .child(
+                    Label::new(bucket.to_string())
+                        .size(LabelSize::XSmall)
+                        .color(Color::Muted),
+                )
+                .into_any_element(),
+        }
+    }
+
+    fn render_history_entry(
+        &self,
+        entry: &AgentSessionInfo,
+        format: EntryTimeFormat,
+        ix: usize,
+        highlight_positions: Vec<usize>,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let selected = ix == self.selected_index;
+        let hovered = Some(ix) == self.hovered_index;
+        let entry_time = entry.updated_at;
+        let display_text = match (format, entry_time) {
+            (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
+                let now = Utc::now();
+                let duration = now.signed_duration_since(entry_time);
+                let days = duration.num_days();
+
+                format!("{}d", days)
+            }
+            (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
+                format.format_timestamp(entry_time.timestamp(), self.local_timezone)
+            }
+            (_, None) => "—".to_string(),
+        };
+
+        let title = thread_title(entry).clone();
+        let full_date = entry_time
+            .map(|time| {
+                EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
+            })
+            .unwrap_or_else(|| "Unknown".to_string());
+
+        let supports_delete = self.history.read(cx).supports_delete();
+
+        h_flex()
+            .w_full()
+            .pb_1()
+            .child(
+                ListItem::new(ix)
+                    .rounded()
+                    .toggle_state(selected)
+                    .spacing(ListItemSpacing::Sparse)
+                    .start_slot(
+                        h_flex()
+                            .w_full()
+                            .gap_2()
+                            .justify_between()
+                            .child(
+                                HighlightedLabel::new(thread_title(entry), highlight_positions)
+                                    .size(LabelSize::Small)
+                                    .truncate(),
+                            )
+                            .child(
+                                Label::new(display_text)
+                                    .color(Color::Muted)
+                                    .size(LabelSize::XSmall),
+                            ),
+                    )
+                    .tooltip(move |_, cx| {
+                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
+                    })
+                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+                        if *is_hovered {
+                            this.hovered_index = Some(ix);
+                        } else if this.hovered_index == Some(ix) {
+                            this.hovered_index = None;
+                        }
+
+                        cx.notify();
+                    }))
+                    .end_slot::<IconButton>(if hovered && supports_delete {
+                        Some(
+                            IconButton::new("delete", IconName::Trash)
+                                .shape(IconButtonShape::Square)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Muted)
+                                .tooltip(move |_window, cx| {
+                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
+                                })
+                                .on_click(cx.listener(move |this, _, _, cx| {
+                                    this.remove_thread(ix, cx);
+                                    cx.stop_propagation()
+                                })),
+                        )
+                    } else {
+                        None
+                    })
+                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
+            )
+            .into_any_element()
+    }
+}
+
+impl Focusable for ThreadHistoryView {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.search_editor.focus_handle(cx)
+    }
+}
+
+impl Render for ThreadHistoryView {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_no_history = self.history.read(cx).is_empty();
+        let supports_delete = self.history.read(cx).supports_delete();
+
+        v_flex()
+            .key_context("ThreadHistory")
+            .size_full()
+            .bg(cx.theme().colors().panel_background)
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::remove_selected_thread))
+            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
+                this.remove_history(window, cx);
+            }))
+            .child(
+                h_flex()
+                    .h(Tab::container_height(cx))
+                    .w_full()
+                    .py_1()
+                    .px_2()
+                    .gap_2()
+                    .justify_between()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
+                    .child(
+                        Icon::new(IconName::MagnifyingGlass)
+                            .color(Color::Muted)
+                            .size(IconSize::Small),
+                    )
+                    .child(self.search_editor.clone()),
+            )
+            .child({
+                let view = v_flex()
+                    .id("list-container")
+                    .relative()
+                    .overflow_hidden()
+                    .flex_grow();
+
+                if has_no_history {
+                    view.justify_center().items_center().child(
+                        Label::new("You don't have any past threads yet.")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                } else if self.search_produced_no_matches() {
+                    view.justify_center()
+                        .items_center()
+                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
+                } else {
+                    view.child(
+                        uniform_list(
+                            "thread-history",
+                            self.visible_items.len(),
+                            cx.processor(|this, range: Range<usize>, window, cx| {
+                                this.render_list_items(range, window, cx)
+                            }),
+                        )
+                        .p_1()
+                        .pr_4()
+                        .track_scroll(&self.scroll_handle)
+                        .flex_grow(),
+                    )
+                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+                }
+            })
+            .when(!has_no_history && supports_delete, |this| {
+                this.child(
+                    h_flex()
+                        .p_2()
+                        .border_t_1()
+                        .border_color(cx.theme().colors().border_variant)
+                        .when(!self.confirming_delete_history, |this| {
+                            this.child(
+                                Button::new("delete_history", "Delete All History")
+                                    .full_width()
+                                    .style(ButtonStyle::Outlined)
+                                    .label_size(LabelSize::Small)
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.prompt_delete_history(window, cx);
+                                    })),
+                            )
+                        })
+                        .when(self.confirming_delete_history, |this| {
+                            this.w_full()
+                                .gap_2()
+                                .flex_wrap()
+                                .justify_between()
+                                .child(
+                                    h_flex()
+                                        .flex_wrap()
+                                        .gap_1()
+                                        .child(
+                                            Label::new("Delete all threads?")
+                                                .size(LabelSize::Small),
+                                        )
+                                        .child(
+                                            Label::new("You won't be able to recover them later.")
+                                                .size(LabelSize::Small)
+                                                .color(Color::Muted),
+                                        ),
+                                )
+                                .child(
+                                    h_flex()
+                                        .gap_1()
+                                        .child(
+                                            Button::new("cancel_delete", "Cancel")
+                                                .label_size(LabelSize::Small)
+                                                .on_click(cx.listener(|this, _, window, cx| {
+                                                    this.cancel_delete_history(window, cx);
+                                                })),
+                                        )
+                                        .child(
+                                            Button::new("confirm_delete", "Delete")
+                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
+                                                .color(Color::Error)
+                                                .label_size(LabelSize::Small)
+                                                .on_click(cx.listener(|_, _, window, cx| {
+                                                    window.dispatch_action(
+                                                        Box::new(RemoveHistory),
+                                                        cx,
+                                                    );
+                                                })),
+                                        ),
+                                )
+                        }),
+                )
+            })
+    }
+}
+
+#[derive(IntoElement)]
+pub struct HistoryEntryElement {
+    entry: AgentSessionInfo,
+    thread_view: WeakEntity<ConnectionView>,
+    selected: bool,
+    hovered: bool,
+    supports_delete: bool,
+    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+}
+
+impl HistoryEntryElement {
+    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
+        Self {
+            entry,
+            thread_view,
+            selected: false,
+            hovered: false,
+            supports_delete: false,
+            on_hover: Box::new(|_, _, _| {}),
+        }
+    }
+
+    pub fn supports_delete(mut self, supports_delete: bool) -> Self {
+        self.supports_delete = supports_delete;
+        self
+    }
+
+    pub fn hovered(mut self, hovered: bool) -> Self {
+        self.hovered = hovered;
+        self
+    }
+
+    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
+        self.on_hover = Box::new(on_hover);
+        self
+    }
+}
+
+impl RenderOnce for HistoryEntryElement {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let id = ElementId::Name(self.entry.session_id.0.clone().into());
+        let title = thread_title(&self.entry).clone();
+        let formatted_time = self
+            .entry
+            .updated_at
+            .map(|timestamp| {
+                let now = chrono::Utc::now();
+                let duration = now.signed_duration_since(timestamp);
+
+                if duration.num_days() > 0 {
+                    format!("{}d", duration.num_days())
+                } else if duration.num_hours() > 0 {
+                    format!("{}h ago", duration.num_hours())
+                } else if duration.num_minutes() > 0 {
+                    format!("{}m ago", duration.num_minutes())
+                } else {
+                    "Just now".to_string()
+                }
+            })
+            .unwrap_or_else(|| "Unknown".to_string());
+
+        ListItem::new(id)
+            .rounded()
+            .toggle_state(self.selected)
+            .spacing(ListItemSpacing::Sparse)
+            .start_slot(
+                h_flex()
+                    .w_full()
+                    .gap_2()
+                    .justify_between()
+                    .child(Label::new(title).size(LabelSize::Small).truncate())
+                    .child(
+                        Label::new(formatted_time)
+                            .color(Color::Muted)
+                            .size(LabelSize::XSmall),
+                    ),
+            )
+            .on_hover(self.on_hover)
+            .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
+                Some(
+                    IconButton::new("delete", IconName::Trash)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::XSmall)
+                        .icon_color(Color::Muted)
+                        .tooltip(move |_window, cx| {
+                            Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
+                        })
+                        .on_click({
+                            let thread_view = self.thread_view.clone();
+                            let session_id = self.entry.session_id.clone();
+
+                            move |_event, _window, cx| {
+                                if let Some(thread_view) = thread_view.upgrade() {
+                                    thread_view.update(cx, |thread_view, cx| {
+                                        thread_view.delete_history_entry(&session_id, cx);
+                                    });
+                                }
+                            }
+                        }),
+                )
+            } else {
+                None
+            })
+            .on_click({
+                let thread_view = self.thread_view.clone();
+                let entry = self.entry;
+
+                move |_event, window, cx| {
+                    if let Some(workspace) = thread_view
+                        .upgrade()
+                        .and_then(|view| view.read(cx).workspace().upgrade())
+                    {
+                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                            panel.update(cx, |panel, cx| {
+                                panel.load_agent_thread(
+                                    entry.session_id.clone(),
+                                    entry.cwd.clone(),
+                                    entry.title.clone(),
+                                    window,
+                                    cx,
+                                );
+                            });
+                        }
+                    }
+                }
+            })
+    }
+}
+
+#[derive(Clone, Copy)]
+pub enum EntryTimeFormat {
+    DateAndTime,
+    TimeOnly,
+}
+
+impl EntryTimeFormat {
+    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
+        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
+
+        match self {
+            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
+                timestamp,
+                OffsetDateTime::now_utc(),
+                timezone,
+                time_format::TimestampFormat::EnhancedAbsolute,
+            ),
+            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
+        }
+    }
+}
+
+impl From<TimeBucket> for EntryTimeFormat {
+    fn from(bucket: TimeBucket) -> Self {
+        match bucket {
+            TimeBucket::Today => EntryTimeFormat::TimeOnly,
+            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
+            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
+            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
+            TimeBucket::All => EntryTimeFormat::DateAndTime,
+        }
+    }
+}
+
+#[derive(PartialEq, Eq, Clone, Copy, Debug)]
+enum TimeBucket {
+    Today,
+    Yesterday,
+    ThisWeek,
+    PastWeek,
+    All,
+}
+
+impl TimeBucket {
+    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
+        if date == reference {
+            return TimeBucket::Today;
+        }
+
+        if date == reference - TimeDelta::days(1) {
+            return TimeBucket::Yesterday;
+        }
+
+        let week = date.iso_week();
+
+        if reference.iso_week() == week {
+            return TimeBucket::ThisWeek;
+        }
+
+        let last_week = (reference - TimeDelta::days(7)).iso_week();
+
+        if week == last_week {
+            return TimeBucket::PastWeek;
+        }
+
+        TimeBucket::All
+    }
+}
+
+impl Display for TimeBucket {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TimeBucket::Today => write!(f, "Today"),
+            TimeBucket::Yesterday => write!(f, "Yesterday"),
+            TimeBucket::ThisWeek => write!(f, "This Week"),
+            TimeBucket::PastWeek => write!(f, "Past Week"),
+            TimeBucket::All => write!(f, "All"),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chrono::NaiveDate;
+
+    #[test]
+    fn test_time_bucket_from_dates() {
+        let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
+
+        assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today);
+
+        let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap();
+        assert_eq!(
+            TimeBucket::from_dates(today, yesterday),
+            TimeBucket::Yesterday
+        );
+
+        let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
+        assert_eq!(
+            TimeBucket::from_dates(today, this_week),
+            TimeBucket::ThisWeek
+        );
+
+        let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap();
+        assert_eq!(
+            TimeBucket::from_dates(today, past_week),
+            TimeBucket::PastWeek
+        );
+
+        let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All);
+    }
+}

crates/collab/migrations/20251208000000_test_schema.sql 🔗

@@ -1,3 +1,6 @@
+-- This file is auto-generated. Do not modify it by hand.
+-- To regenerate, run `cargo xtask db dump-schema app --collab` from the Cloud repository.
+
 CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
 
 CREATE TABLE public.breakpoints (
@@ -315,10 +318,10 @@ CREATE TABLE public.project_repository_statuses (
     status_kind integer NOT NULL,
     first_status integer,
     second_status integer,
-    lines_added integer,
-    lines_deleted integer,
     scan_id bigint NOT NULL,
-    is_deleted boolean NOT NULL
+    is_deleted boolean NOT NULL,
+    lines_added integer,
+    lines_deleted integer
 );
 
 CREATE TABLE public.projects (
@@ -706,6 +709,8 @@ CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name
 
 CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops);
 
+CREATE INDEX trigram_index_users_on_name ON public.users USING gin (name public.gin_trgm_ops);
+
 CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text));
 
 CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id);
@@ -753,7 +758,7 @@ ALTER TABLE ONLY public.contacts
     ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE;
 
 ALTER TABLE ONLY public.contributors
-    ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
+    ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
 
 ALTER TABLE ONLY public.extension_versions
     ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id);

crates/copilot/src/copilot.rs 🔗

@@ -1779,6 +1779,7 @@ mod tests {
         fn disk_state(&self) -> language::DiskState {
             language::DiskState::Present {
                 mtime: ::fs::MTime::from_seconds_and_nanos(100, 42),
+                size: 0,
             }
         }
 

crates/crashes/src/crashes.rs 🔗

@@ -350,8 +350,34 @@ impl minidumper::ServerHandler for CrashServer {
     }
 }
 
+/// Rust's string-slicing panics embed the user's string content in the message,
+/// e.g. "byte index 4 is out of bounds of `a`". Strip that suffix so we
+/// don't upload arbitrary user text in crash reports.
+fn strip_user_string_from_panic(message: &str) -> String {
+    const STRING_PANIC_PREFIXES: &[&str] = &[
+        // Older rustc (pre-1.95):
+        "byte index ",
+        "begin <= end (",
+        // Newer rustc (1.95+):
+        // https://github.com/rust-lang/rust/pull/145024
+        "start byte index ",
+        "end byte index ",
+        "begin > end (",
+    ];
+
+    if (message.ends_with('`') || message.ends_with("`[...]"))
+        && STRING_PANIC_PREFIXES
+            .iter()
+            .any(|prefix| message.starts_with(prefix))
+        && let Some(open) = message.find('`')
+    {
+        return format!("{} `<redacted>`", &message[..open]);
+    }
+    message.to_owned()
+}
+
 pub fn panic_hook(info: &PanicHookInfo) {
-    let message = info.payload_as_str().unwrap_or("Box<Any>").to_owned();
+    let message = strip_user_string_from_panic(info.payload_as_str().unwrap_or("Box<Any>"));
 
     let span = info
         .location()

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

@@ -1211,7 +1211,9 @@ async fn test_stack_frame_filter_persistence(
     cx.run_until_parked();
 
     let workspace_id = workspace
-        .update(cx, |workspace, _window, cx| workspace.database_id(cx))
+        .update(cx, |workspace, _window, cx| {
+            workspace.active_workspace_database_id(cx)
+        })
         .ok()
         .flatten()
         .expect("workspace id has to be some for this test to work properly");

crates/editor/src/document_colors.rs 🔗

@@ -145,7 +145,7 @@ impl Editor {
         _: &Window,
         cx: &mut Context<Self>,
     ) {
-        if !self.mode().is_full() {
+        if !self.lsp_data_enabled() {
             return;
         }
         let Some(project) = self.project.as_ref() else {

crates/editor/src/document_symbols.rs 🔗

@@ -147,7 +147,7 @@ impl Editor {
         for_buffer: Option<BufferId>,
         cx: &mut Context<Self>,
     ) {
-        if !self.mode().is_full() {
+        if !self.lsp_data_enabled() {
             return;
         }
         let Some(project) = self.project.clone() else {

crates/editor/src/editor.rs 🔗

@@ -35,13 +35,13 @@ mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;
+mod runnables;
 mod rust_analyzer_ext;
 pub mod scroll;
 mod selections_collection;
 pub mod semantic_tokens;
 mod split;
 pub mod split_editor_view;
-pub mod tasks;
 
 #[cfg(test)]
 mod code_completion_tests;
@@ -133,8 +133,8 @@ use language::{
     BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
     DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
     IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt,
-    OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
-    TreeSitterOptions, WordsQuery,
+    OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
+    WordsQuery,
     language_settings::{
         self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
         all_language_settings, language_settings,
@@ -158,7 +158,7 @@ use project::{
     BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
     CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId,
     InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project,
-    ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
+    ProjectItem, ProjectPath, ProjectTransaction,
     debugger::{
         breakpoint_store::{
             Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
@@ -200,7 +200,7 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
+use task::TaskVariables;
 use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _};
 use theme::{
     AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme,
@@ -231,6 +231,7 @@ use crate::{
         InlineValueCache,
         inlay_hints::{LspInlayHintData, inlay_hint_settings},
     },
+    runnables::{ResolvedTasks, RunnableData, RunnableTasks},
     scroll::{ScrollOffset, ScrollPixelOffset},
     selections_collection::resolve_selections_wrapping_blocks,
     semantic_tokens::SemanticTokenState,
@@ -857,37 +858,6 @@ impl BufferSerialization {
     }
 }
 
-#[derive(Clone, Debug)]
-struct RunnableTasks {
-    templates: Vec<(TaskSourceKind, TaskTemplate)>,
-    offset: multi_buffer::Anchor,
-    // We need the column at which the task context evaluation should take place (when we're spawning it via gutter).
-    column: u32,
-    // Values of all named captures, including those starting with '_'
-    extra_variables: HashMap<String, String>,
-    // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal.
-    context_range: Range<BufferOffset>,
-}
-
-impl RunnableTasks {
-    fn resolve<'a>(
-        &'a self,
-        cx: &'a task::TaskContext,
-    ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
-        self.templates.iter().filter_map(|(kind, template)| {
-            template
-                .resolve_task(&kind.to_id_base(), cx)
-                .map(|task| (kind.clone(), task))
-        })
-    }
-}
-
-#[derive(Clone)]
-pub struct ResolvedTasks {
-    templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
-    position: Anchor,
-}
-
 /// Addons allow storing per-editor state in other crates (e.g. Vim)
 pub trait Addon: 'static {
     fn extend_key_context(&self, _: &mut KeyContext, _: &App) {}
@@ -1295,8 +1265,7 @@ pub struct Editor {
     last_bounds: Option<Bounds<Pixels>>,
     last_position_map: Option<Rc<PositionMap>>,
     expect_bounds_change: Option<Bounds<Pixels>>,
-    tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
-    tasks_update_task: Option<Task<()>>,
+    runnables: RunnableData,
     breakpoint_store: Option<Entity<BreakpointStore>>,
     gutter_breakpoint_indicator: (Option<PhantomBreakpointIndicator>, Option<Task<()>>),
     pub(crate) gutter_diff_review_indicator: (Option<PhantomDiffReviewIndicator>, Option<Task<()>>),
@@ -2173,16 +2142,9 @@ impl Editor {
                         editor.registered_buffers.clear();
                         editor.register_visible_buffers(cx);
                         editor.invalidate_semantic_tokens(None);
+                        editor.refresh_runnables(window, cx);
                         editor.update_lsp_data(None, window, cx);
                         editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
-                        if editor.tasks_update_task.is_none() {
-                            editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
-                        }
-                    }
-                    project::Event::LanguageServerAdded(..) => {
-                        if editor.tasks_update_task.is_none() {
-                            editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
-                        }
                     }
                     project::Event::SnippetEdit(id, snippet_edits) => {
                         // todo(lw): Non singletons
@@ -2210,6 +2172,7 @@ impl Editor {
                         let buffer_id = *buffer_id;
                         if editor.buffer().read(cx).buffer(buffer_id).is_some() {
                             editor.register_buffer(buffer_id, cx);
+                            editor.refresh_runnables(window, cx);
                             editor.update_lsp_data(Some(buffer_id), window, cx);
                             editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
                             refresh_linked_ranges(editor, window, cx);
@@ -2288,7 +2251,7 @@ impl Editor {
                     &task_inventory,
                     window,
                     |editor, _, window, cx| {
-                        editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
+                        editor.refresh_runnables(window, cx);
                     },
                 ));
             };
@@ -2529,7 +2492,6 @@ impl Editor {
             }),
             blame: None,
             blame_subscription: None,
-            tasks: BTreeMap::default(),
 
             breakpoint_store,
             gutter_breakpoint_indicator: (None, None),
@@ -2565,7 +2527,7 @@ impl Editor {
                     ]
                 })
                 .unwrap_or_default(),
-            tasks_update_task: None,
+            runnables: RunnableData::new(),
             pull_diagnostics_task: Task::ready(()),
             colors: None,
             refresh_colors_task: Task::ready(()),
@@ -2632,7 +2594,6 @@ impl Editor {
                     cx.notify();
                 }));
         }
-        editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
         editor._subscriptions.extend(project_subscriptions);
 
         editor._subscriptions.push(cx.subscribe_in(
@@ -2668,6 +2629,7 @@ impl Editor {
                                     );
                                     if !editor.buffer().read(cx).is_singleton() {
                                         editor.update_lsp_data(None, window, cx);
+                                        editor.refresh_runnables(window, cx);
                                     }
                                 })
                                 .ok();
@@ -5791,18 +5753,11 @@ impl Editor {
         let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let multi_buffer = self.buffer().read(cx);
         let multi_buffer_snapshot = multi_buffer.snapshot(cx);
-        let multi_buffer_visible_start = self
-            .scroll_manager
-            .native_anchor(&display_snapshot, cx)
-            .anchor
-            .to_point(&multi_buffer_snapshot);
-        let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
-            multi_buffer_visible_start
-                + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
-            Bias::Left,
-        );
         multi_buffer_snapshot
-            .range_to_buffer_ranges(multi_buffer_visible_start..=multi_buffer_visible_end)
+            .range_to_buffer_ranges(
+                self.multi_buffer_visible_range(&display_snapshot, cx)
+                    .to_inclusive(),
+            )
             .into_iter()
             .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty())
             .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| {
@@ -6737,8 +6692,8 @@ impl Editor {
         };
         let buffer_id = buffer.read(cx).remote_id();
         let tasks = self
-            .tasks
-            .get(&(buffer_id, buffer_row))
+            .runnables
+            .runnables((buffer_id, buffer_row))
             .map(|t| Arc::new(t.to_owned()));
 
         if !self.focus_handle.is_focused(window) {
@@ -7733,7 +7688,7 @@ impl Editor {
 
     #[ztracing::instrument(skip_all)]
     fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context<Editor>) {
-        if !self.mode.is_full() {
+        if !self.lsp_data_enabled() {
             return;
         }
         let cursor = self.selections.newest_anchor().head();
@@ -7789,24 +7744,13 @@ impl Editor {
             self.debounced_selection_highlight_complete = false;
         }
         if on_buffer_edit || query_changed {
-            let multi_buffer_visible_start = self
-                .scroll_manager
-                .native_anchor(&display_snapshot, cx)
-                .anchor
-                .to_point(&multi_buffer_snapshot);
-            let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
-                multi_buffer_visible_start
-                    + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
-                Bias::Left,
-            );
-            let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end;
             self.quick_selection_highlight_task = Some((
                 query_range.clone(),
                 self.update_selection_occurrence_highlights(
                     snapshot.buffer.clone(),
                     query_text.clone(),
                     query_range.clone(),
-                    multi_buffer_visible_range,
+                    self.multi_buffer_visible_range(&display_snapshot, cx),
                     false,
                     window,
                     cx,
@@ -7841,6 +7785,27 @@ impl Editor {
         }
     }
 
+    pub fn multi_buffer_visible_range(
+        &self,
+        display_snapshot: &DisplaySnapshot,
+        cx: &App,
+    ) -> Range<Point> {
+        let visible_start = self
+            .scroll_manager
+            .native_anchor(display_snapshot, cx)
+            .anchor
+            .to_point(display_snapshot.buffer_snapshot())
+            .to_display_point(display_snapshot);
+
+        let mut target_end = visible_start;
+        *target_end.row_mut() += self.visible_line_count().unwrap_or(0.).ceil() as u32;
+
+        visible_start.to_point(display_snapshot)
+            ..display_snapshot
+                .clip_point(target_end, Bias::Right)
+                .to_point(display_snapshot)
+    }
+
     pub fn refresh_edit_prediction(
         &mut self,
         debounce: bool,
@@ -8809,19 +8774,6 @@ impl Editor {
         Some(self.edit_prediction_provider.as_ref()?.provider.clone())
     }
 
-    fn clear_tasks(&mut self) {
-        self.tasks.clear()
-    }
-
-    fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) {
-        if self.tasks.insert(key, value).is_some() {
-            // This case should hopefully be rare, but just in case...
-            log::error!(
-                "multiple different run targets found on a single line, only the last target will be rendered"
-            )
-        }
-    }
-
     /// Get all display points of breakpoints that will be rendered within editor
     ///
     /// This function is used to handle overlaps between breakpoints and Code action/runner symbol.
@@ -9199,156 +9151,6 @@ impl Editor {
         })
     }
 
-    pub fn spawn_nearest_task(
-        &mut self,
-        action: &SpawnNearestTask,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some((workspace, _)) = self.workspace.clone() else {
-            return;
-        };
-        let Some(project) = self.project.clone() else {
-            return;
-        };
-
-        // Try to find a closest, enclosing node using tree-sitter that has a task
-        let Some((buffer, buffer_row, tasks)) = self
-            .find_enclosing_node_task(cx)
-            // Or find the task that's closest in row-distance.
-            .or_else(|| self.find_closest_task(cx))
-        else {
-            return;
-        };
-
-        let reveal_strategy = action.reveal;
-        let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
-        cx.spawn_in(window, async move |_, cx| {
-            let context = task_context.await?;
-            let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
-
-            let resolved = &mut resolved_task.resolved;
-            resolved.reveal = reveal_strategy;
-
-            workspace
-                .update_in(cx, |workspace, window, cx| {
-                    workspace.schedule_resolved_task(
-                        task_source_kind,
-                        resolved_task,
-                        false,
-                        window,
-                        cx,
-                    );
-                })
-                .ok()
-        })
-        .detach();
-    }
-
-    fn find_closest_task(
-        &mut self,
-        cx: &mut Context<Self>,
-    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
-        let cursor_row = self
-            .selections
-            .newest_adjusted(&self.display_snapshot(cx))
-            .head()
-            .row;
-
-        let ((buffer_id, row), tasks) = self
-            .tasks
-            .iter()
-            .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
-
-        let buffer = self.buffer.read(cx).buffer(*buffer_id)?;
-        let tasks = Arc::new(tasks.to_owned());
-        Some((buffer, *row, tasks))
-    }
-
-    fn find_enclosing_node_task(
-        &mut self,
-        cx: &mut Context<Self>,
-    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
-        let offset = self
-            .selections
-            .newest::<MultiBufferOffset>(&self.display_snapshot(cx))
-            .head();
-        let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
-        let offset = excerpt.map_offset_to_buffer(offset);
-        let buffer_id = excerpt.buffer().remote_id();
-
-        let layer = excerpt.buffer().syntax_layer_at(offset)?;
-        let mut cursor = layer.node().walk();
-
-        while cursor.goto_first_child_for_byte(offset.0).is_some() {
-            if cursor.node().end_byte() == offset.0 {
-                cursor.goto_next_sibling();
-            }
-        }
-
-        // Ascend to the smallest ancestor that contains the range and has a task.
-        loop {
-            let node = cursor.node();
-            let node_range = node.byte_range();
-            let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
-
-            // Check if this node contains our offset
-            if node_range.start <= offset.0 && node_range.end >= offset.0 {
-                // If it contains offset, check for task
-                if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) {
-                    let buffer = self.buffer.read(cx).buffer(buffer_id)?;
-                    return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
-                }
-            }
-
-            if !cursor.goto_parent() {
-                break;
-            }
-        }
-        None
-    }
-
-    fn render_run_indicator(
-        &self,
-        _style: &EditorStyle,
-        is_active: bool,
-        row: DisplayRow,
-        breakpoint: Option<(Anchor, Breakpoint, Option<BreakpointSessionState>)>,
-        cx: &mut Context<Self>,
-    ) -> IconButton {
-        let color = Color::Muted;
-        let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor);
-
-        IconButton::new(
-            ("run_indicator", row.0 as usize),
-            ui::IconName::PlayOutlined,
-        )
-        .shape(ui::IconButtonShape::Square)
-        .icon_size(IconSize::XSmall)
-        .icon_color(color)
-        .toggle_state(is_active)
-        .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| {
-            let quick_launch = match e {
-                ClickEvent::Keyboard(_) => true,
-                ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
-            };
-
-            window.focus(&editor.focus_handle(cx), cx);
-            editor.toggle_code_actions(
-                &ToggleCodeActions {
-                    deployed_from: Some(CodeActionSource::RunMenu(row)),
-                    quick_launch,
-                },
-                window,
-                cx,
-            );
-        }))
-        .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
-            editor.set_breakpoint_context_menu(row, position, event.position(), window, cx);
-        }))
-    }
-
     pub fn context_menu_visible(&self) -> bool {
         !self.edit_prediction_preview_is_active()
             && self
@@ -17153,236 +16955,6 @@ impl Editor {
         });
     }
 
-    fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
-        if !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables {
-            self.clear_tasks();
-            return Task::ready(());
-        }
-        let project = self.project().map(Entity::downgrade);
-        let task_sources = self.lsp_task_sources(cx);
-        let multi_buffer = self.buffer.downgrade();
-        cx.spawn_in(window, async move |editor, cx| {
-            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
-            let Some(project) = project.and_then(|p| p.upgrade()) else {
-                return;
-            };
-            let Ok(display_snapshot) = editor.update(cx, |this, cx| {
-                this.display_map.update(cx, |map, cx| map.snapshot(cx))
-            }) else {
-                return;
-            };
-
-            let hide_runnables = project.update(cx, |project, _| project.is_via_collab());
-            if hide_runnables {
-                return;
-            }
-            let new_rows =
-                cx.background_spawn({
-                    let snapshot = display_snapshot.clone();
-                    async move {
-                        Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max())
-                    }
-                })
-                    .await;
-            let Ok(lsp_tasks) =
-                cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx))
-            else {
-                return;
-            };
-            let lsp_tasks = lsp_tasks.await;
-
-            let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| {
-                lsp_tasks
-                    .into_iter()
-                    .flat_map(|(kind, tasks)| {
-                        tasks.into_iter().filter_map(move |(location, task)| {
-                            Some((kind.clone(), location?, task))
-                        })
-                    })
-                    .fold(HashMap::default(), |mut acc, (kind, location, task)| {
-                        let buffer = location.target.buffer;
-                        let buffer_snapshot = buffer.read(cx).snapshot();
-                        let offset = display_snapshot.buffer_snapshot().excerpts().find_map(
-                            |(excerpt_id, snapshot, _)| {
-                                if snapshot.remote_id() == buffer_snapshot.remote_id() {
-                                    display_snapshot
-                                        .buffer_snapshot()
-                                        .anchor_in_excerpt(excerpt_id, location.target.range.start)
-                                } else {
-                                    None
-                                }
-                            },
-                        );
-                        if let Some(offset) = offset {
-                            let task_buffer_range =
-                                location.target.range.to_point(&buffer_snapshot);
-                            let context_buffer_range =
-                                task_buffer_range.to_offset(&buffer_snapshot);
-                            let context_range = BufferOffset(context_buffer_range.start)
-                                ..BufferOffset(context_buffer_range.end);
-
-                            acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row))
-                                .or_insert_with(|| RunnableTasks {
-                                    templates: Vec::new(),
-                                    offset,
-                                    column: task_buffer_range.start.column,
-                                    extra_variables: HashMap::default(),
-                                    context_range,
-                                })
-                                .templates
-                                .push((kind, task.original_task().clone()));
-                        }
-
-                        acc
-                    })
-            }) else {
-                return;
-            };
-
-            let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
-                buffer.language_settings(cx).tasks.prefer_lsp
-            }) else {
-                return;
-            };
-
-            let rows = Self::runnable_rows(
-                project,
-                display_snapshot,
-                prefer_lsp && !lsp_tasks_by_rows.is_empty(),
-                new_rows,
-                cx.clone(),
-            )
-            .await;
-            editor
-                .update(cx, |editor, _| {
-                    editor.clear_tasks();
-                    for (key, mut value) in rows {
-                        if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) {
-                            value.templates.extend(lsp_tasks.templates);
-                        }
-
-                        editor.insert_tasks(key, value);
-                    }
-                    for (key, value) in lsp_tasks_by_rows {
-                        editor.insert_tasks(key, value);
-                    }
-                })
-                .ok();
-        })
-    }
-    fn fetch_runnable_ranges(
-        snapshot: &DisplaySnapshot,
-        range: Range<Anchor>,
-    ) -> Vec<(Range<MultiBufferOffset>, language::RunnableRange)> {
-        snapshot.buffer_snapshot().runnable_ranges(range).collect()
-    }
-
-    fn runnable_rows(
-        project: Entity<Project>,
-        snapshot: DisplaySnapshot,
-        prefer_lsp: bool,
-        runnable_ranges: Vec<(Range<MultiBufferOffset>, language::RunnableRange)>,
-        cx: AsyncWindowContext,
-    ) -> Task<Vec<((BufferId, BufferRow), RunnableTasks)>> {
-        cx.spawn(async move |cx| {
-            let mut runnable_rows = Vec::with_capacity(runnable_ranges.len());
-            for (run_range, mut runnable) in runnable_ranges {
-                let Some(tasks) = cx
-                    .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
-                    .ok()
-                else {
-                    continue;
-                };
-                let mut tasks = tasks.await;
-
-                if prefer_lsp {
-                    tasks.retain(|(task_kind, _)| {
-                        !matches!(task_kind, TaskSourceKind::Language { .. })
-                    });
-                }
-                if tasks.is_empty() {
-                    continue;
-                }
-
-                let point = run_range.start.to_point(&snapshot.buffer_snapshot());
-                let Some(row) = snapshot
-                    .buffer_snapshot()
-                    .buffer_line_for_row(MultiBufferRow(point.row))
-                    .map(|(_, range)| range.start.row)
-                else {
-                    continue;
-                };
-
-                let context_range =
-                    BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end);
-                runnable_rows.push((
-                    (runnable.buffer_id, row),
-                    RunnableTasks {
-                        templates: tasks,
-                        offset: snapshot.buffer_snapshot().anchor_before(run_range.start),
-                        context_range,
-                        column: point.column,
-                        extra_variables: runnable.extra_captures,
-                    },
-                ));
-            }
-            runnable_rows
-        })
-    }
-
-    fn templates_with_tags(
-        project: &Entity<Project>,
-        runnable: &mut Runnable,
-        cx: &mut App,
-    ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
-        let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
-            let (worktree_id, file) = project
-                .buffer_for_id(runnable.buffer, cx)
-                .and_then(|buffer| buffer.read(cx).file())
-                .map(|file| (file.worktree_id(cx), file.clone()))
-                .unzip();
-
-            (
-                project.task_store().read(cx).task_inventory().cloned(),
-                worktree_id,
-                file,
-            )
-        });
-
-        let tags = mem::take(&mut runnable.tags);
-        let language = runnable.language.clone();
-        cx.spawn(async move |cx| {
-            let mut templates_with_tags = Vec::new();
-            if let Some(inventory) = inventory {
-                for RunnableTag(tag) in tags {
-                    let new_tasks = inventory.update(cx, |inventory, cx| {
-                        inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx)
-                    });
-                    templates_with_tags.extend(new_tasks.await.into_iter().filter(
-                        move |(_, template)| {
-                            template.tags.iter().any(|source_tag| source_tag == &tag)
-                        },
-                    ));
-                }
-            }
-            templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned());
-
-            if let Some((leading_tag_source, _)) = templates_with_tags.first() {
-                // Strongest source wins; if we have worktree tag binding, prefer that to
-                // global and language bindings;
-                // if we have a global binding, prefer that to language binding.
-                let first_mismatch = templates_with_tags
-                    .iter()
-                    .position(|(tag_source, _)| tag_source != leading_tag_source);
-                if let Some(index) = first_mismatch {
-                    templates_with_tags.truncate(index);
-                }
-            }
-
-            templates_with_tags
-        })
-    }
-
     pub fn move_to_enclosing_bracket(
         &mut self,
         _: &MoveToEnclosingBracket,
@@ -19607,7 +19179,7 @@ impl Editor {
     }
 
     pub fn diagnostics_enabled(&self) -> bool {
-        self.diagnostics_enabled && self.mode.is_full()
+        self.diagnostics_enabled && self.lsp_data_enabled()
     }
 
     pub fn inline_diagnostics_enabled(&self) -> bool {
@@ -19771,10 +19343,7 @@ impl Editor {
         // `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view,
         // skip any LSP updates for it.
 
-        if self.active_diagnostics == ActiveDiagnostic::All
-            || !self.mode().is_full()
-            || !self.diagnostics_enabled()
-        {
+        if self.active_diagnostics == ActiveDiagnostic::All || !self.diagnostics_enabled() {
             return None;
         }
         let pull_diagnostics_settings = ProjectSettings::get_global(cx)
@@ -24182,7 +23751,6 @@ impl Editor {
                 predecessor,
                 excerpts,
             } => {
-                self.tasks_update_task = Some(self.refresh_runnables(window, cx));
                 let buffer_id = buffer.read(cx).remote_id();
                 if self.buffer.read(cx).diff_for(buffer_id).is_none()
                     && let Some(project) = &self.project
@@ -24200,6 +23768,7 @@ impl Editor {
                     .invalidate_buffer(&buffer.read(cx).remote_id());
                 self.update_lsp_data(Some(buffer_id), window, cx);
                 self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
+                self.refresh_runnables(window, cx);
                 self.colorize_brackets(false, cx);
                 self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
                 cx.emit(EditorEvent::ExcerptsAdded {
@@ -24218,8 +23787,7 @@ impl Editor {
                 self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx);
                 for buffer_id in removed_buffer_ids {
                     self.registered_buffers.remove(buffer_id);
-                    self.tasks
-                        .retain(|(task_buffer_id, _), _| task_buffer_id != buffer_id);
+                    self.clear_runnables(Some(*buffer_id));
                     self.semantic_token_state.invalidate_buffer(buffer_id);
                     self.display_map.update(cx, |display_map, cx| {
                         display_map.invalidate_semantic_highlights(*buffer_id);
@@ -24261,10 +23829,12 @@ impl Editor {
                 }
                 self.colorize_brackets(false, cx);
                 self.update_lsp_data(None, window, cx);
+                self.refresh_runnables(window, cx);
                 cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() })
             }
             multi_buffer::Event::Reparsed(buffer_id) => {
-                self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+                self.clear_runnables(Some(*buffer_id));
+                self.refresh_runnables(window, cx);
                 self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
                 self.colorize_brackets(true, cx);
                 jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
@@ -24272,7 +23842,7 @@ impl Editor {
                 cx.emit(EditorEvent::Reparsed(*buffer_id));
             }
             multi_buffer::Event::DiffHunksToggled => {
-                self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+                self.refresh_runnables(window, cx);
             }
             multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => {
                 if !is_fresh_language {
@@ -24408,7 +23978,7 @@ impl Editor {
                 .unwrap_or(DiagnosticSeverity::Hint);
             self.set_max_diagnostics_severity(new_severity, cx);
         }
-        self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+        self.refresh_runnables(window, cx);
         self.update_edit_prediction_settings(cx);
         self.refresh_edit_prediction(true, false, window, cx);
         self.refresh_inline_values(cx);
@@ -25628,13 +25198,17 @@ impl Editor {
         }
     }
 
+    fn lsp_data_enabled(&self) -> bool {
+        self.enable_lsp_data && self.mode().is_full()
+    }
+
     fn update_lsp_data(
         &mut self,
         for_buffer: Option<BufferId>,
         window: &mut Window,
         cx: &mut Context<'_, Self>,
     ) {
-        if !self.enable_lsp_data {
+        if !self.lsp_data_enabled() {
             return;
         }
 
@@ -25648,7 +25222,7 @@ impl Editor {
     }
 
     fn register_visible_buffers(&mut self, cx: &mut Context<Self>) {
-        if !self.mode().is_full() {
+        if !self.lsp_data_enabled() {
             return;
         }
         for (_, (visible_buffer, _, _)) in self.visible_excerpts(true, cx) {
@@ -25657,7 +25231,7 @@ impl Editor {
     }
 
     fn register_buffer(&mut self, buffer_id: BufferId, cx: &mut Context<Self>) {
-        if !self.mode().is_full() {
+        if !self.lsp_data_enabled() {
             return;
         }
 

crates/editor/src/editor_tests.rs 🔗

@@ -5,6 +5,7 @@ use crate::{
     edit_prediction_tests::FakeEditPredictionDelegate,
     element::StickyHeader,
     linked_editing_ranges::LinkedEditingRanges,
+    runnables::RunnableTasks,
     scroll::scroll_amount::ScrollAmount,
     test::{
         assert_text_with_selections, build_editor, editor_content_with_blocks,
@@ -24403,20 +24404,24 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) {
 
     editor.update_in(cx, |editor, window, cx| {
         let snapshot = editor.buffer().read(cx).snapshot(cx);
-        editor.tasks.insert(
-            (buffer.read(cx).remote_id(), 3),
+        editor.runnables.insert(
+            buffer.read(cx).remote_id(),
+            3,
+            buffer.read(cx).version(),
             RunnableTasks {
-                templates: vec![],
+                templates: Vec::new(),
                 offset: snapshot.anchor_before(MultiBufferOffset(43)),
                 column: 0,
                 extra_variables: HashMap::default(),
                 context_range: BufferOffset(43)..BufferOffset(85),
             },
         );
-        editor.tasks.insert(
-            (buffer.read(cx).remote_id(), 8),
+        editor.runnables.insert(
+            buffer.read(cx).remote_id(),
+            8,
+            buffer.read(cx).version(),
             RunnableTasks {
-                templates: vec![],
+                templates: Vec::new(),
                 offset: snapshot.anchor_before(MultiBufferOffset(86)),
                 column: 0,
                 extra_variables: HashMap::default(),

crates/editor/src/element.rs 🔗

@@ -3275,9 +3275,9 @@ impl EditorElement {
                 snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right);
 
             editor
-                .tasks
-                .iter()
-                .filter_map(|(_, tasks)| {
+                .runnables
+                .all_runnables()
+                .filter_map(|tasks| {
                     let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot());
                     if multibuffer_point < offset_range_start
                         || multibuffer_point > offset_range_end

crates/editor/src/folding_ranges.rs 🔗

@@ -13,7 +13,7 @@ impl Editor {
         _window: &Window,
         cx: &mut Context<Self>,
     ) {
-        if !self.mode().is_full() || !self.use_document_folding_ranges {
+        if !self.lsp_data_enabled() || !self.use_document_folding_ranges {
             return;
         }
         let Some(project) = self.project.clone() else {

crates/editor/src/inlays/inlay_hints.rs 🔗

@@ -292,7 +292,7 @@ impl Editor {
         reason: InlayHintRefreshReason,
         cx: &mut Context<Self>,
     ) {
-        if !self.mode().is_full() || self.inlay_hints.is_none() {
+        if !self.lsp_data_enabled() || self.inlay_hints.is_none() {
             return;
         }
         let Some(semantics_provider) = self.semantics_provider() else {

crates/editor/src/linked_editing_ranges.rs 🔗

@@ -50,7 +50,7 @@ pub(super) fn refresh_linked_ranges(
     window: &mut Window,
     cx: &mut Context<Editor>,
 ) -> Option<()> {
-    if !editor.mode().is_full() || editor.pending_rename.is_some() {
+    if !editor.lsp_data_enabled() || editor.pending_rename.is_some() {
         return None;
     }
     let project = editor.project()?.downgrade();

crates/editor/src/runnables.rs 🔗

@@ -0,0 +1,915 @@
+use std::{collections::BTreeMap, mem, ops::Range, sync::Arc};
+
+use clock::Global;
+use collections::HashMap;
+use gpui::{
+    App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _,
+    MouseButton, Task, Window,
+};
+use language::{Buffer, BufferRow, Runnable};
+use lsp::LanguageServerName;
+use multi_buffer::{
+    Anchor, BufferOffset, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToPoint as _,
+};
+use project::{
+    Location, Project, TaskSourceKind,
+    debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
+    project_settings::ProjectSettings,
+};
+use settings::Settings as _;
+use smallvec::SmallVec;
+use task::{ResolvedTask, RunnableTag, TaskContext, TaskTemplate, TaskVariables, VariableName};
+use text::{BufferId, OffsetRangeExt as _, ToOffset as _, ToPoint as _};
+use ui::{Clickable as _, Color, IconButton, IconSize, Toggleable as _};
+
+use crate::{
+    CodeActionSource, Editor, EditorSettings, EditorStyle, RangeToAnchorExt, SpawnNearestTask,
+    ToggleCodeActions, UPDATE_DEBOUNCE, display_map::DisplayRow,
+};
+
+#[derive(Debug)]
+pub(super) struct RunnableData {
+    runnables: HashMap<BufferId, (Global, BTreeMap<BufferRow, RunnableTasks>)>,
+    runnables_update_task: Task<()>,
+}
+
+impl RunnableData {
+    pub fn new() -> Self {
+        Self {
+            runnables: HashMap::default(),
+            runnables_update_task: Task::ready(()),
+        }
+    }
+
+    pub fn runnables(
+        &self,
+        (buffer_id, buffer_row): (BufferId, BufferRow),
+    ) -> Option<&RunnableTasks> {
+        self.runnables.get(&buffer_id)?.1.get(&buffer_row)
+    }
+
+    pub fn all_runnables(&self) -> impl Iterator<Item = &RunnableTasks> {
+        self.runnables
+            .values()
+            .flat_map(|(_, tasks)| tasks.values())
+    }
+
+    pub fn has_cached(&self, buffer_id: BufferId, version: &Global) -> bool {
+        self.runnables
+            .get(&buffer_id)
+            .is_some_and(|(cached_version, _)| !version.changed_since(cached_version))
+    }
+
+    #[cfg(test)]
+    pub fn insert(
+        &mut self,
+        buffer_id: BufferId,
+        buffer_row: BufferRow,
+        version: Global,
+        tasks: RunnableTasks,
+    ) {
+        self.runnables
+            .entry(buffer_id)
+            .or_insert_with(|| (version, BTreeMap::default()))
+            .1
+            .insert(buffer_row, tasks);
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct RunnableTasks {
+    pub templates: Vec<(TaskSourceKind, TaskTemplate)>,
+    pub offset: multi_buffer::Anchor,
+    // We need the column at which the task context evaluation should take place (when we're spawning it via gutter).
+    pub column: u32,
+    // Values of all named captures, including those starting with '_'
+    pub extra_variables: HashMap<String, String>,
+    // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal.
+    pub context_range: Range<BufferOffset>,
+}
+
+impl RunnableTasks {
+    pub fn resolve<'a>(
+        &'a self,
+        cx: &'a task::TaskContext,
+    ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
+        self.templates.iter().filter_map(|(kind, template)| {
+            template
+                .resolve_task(&kind.to_id_base(), cx)
+                .map(|task| (kind.clone(), task))
+        })
+    }
+}
+
+#[derive(Clone)]
+pub struct ResolvedTasks {
+    pub templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
+    pub position: Anchor,
+}
+
+impl Editor {
+    pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.mode().is_full()
+            || !EditorSettings::get_global(cx).gutter.runnables
+            || !self.enable_runnables
+        {
+            self.clear_runnables(None);
+            return;
+        }
+        if let Some(buffer) = self.buffer().read(cx).as_singleton() {
+            if self
+                .runnables
+                .has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version())
+            {
+                return;
+            }
+        }
+
+        let project = self.project().map(Entity::downgrade);
+        let lsp_task_sources = self.lsp_task_sources(true, true, cx);
+        let multi_buffer = self.buffer.downgrade();
+        self.runnables.runnables_update_task = cx.spawn_in(window, async move |editor, cx| {
+            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
+            let Some(project) = project.and_then(|p| p.upgrade()) else {
+                return;
+            };
+
+            let hide_runnables = project.update(cx, |project, _| project.is_via_collab());
+            if hide_runnables {
+                return;
+            }
+            let lsp_tasks = if lsp_task_sources.is_empty() {
+                Vec::new()
+            } else {
+                let Ok(lsp_tasks) = cx
+                    .update(|_, cx| crate::lsp_tasks(project.clone(), &lsp_task_sources, None, cx))
+                else {
+                    return;
+                };
+                lsp_tasks.await
+            };
+            let new_rows = {
+                let Some((multi_buffer_snapshot, multi_buffer_query_range)) = editor
+                    .update(cx, |editor, cx| {
+                        let multi_buffer = editor.buffer().read(cx);
+                        if multi_buffer.is_singleton() {
+                            Some((multi_buffer.snapshot(cx), Anchor::min()..Anchor::max()))
+                        } else {
+                            let display_snapshot =
+                                editor.display_map.update(cx, |map, cx| map.snapshot(cx));
+                            let multi_buffer_query_range =
+                                editor.multi_buffer_visible_range(&display_snapshot, cx);
+                            let multi_buffer_snapshot = display_snapshot.buffer();
+                            Some((
+                                multi_buffer_snapshot.clone(),
+                                multi_buffer_query_range.to_anchors(&multi_buffer_snapshot),
+                            ))
+                        }
+                    })
+                    .ok()
+                    .flatten()
+                else {
+                    return;
+                };
+                cx.background_spawn({
+                    async move {
+                        multi_buffer_snapshot
+                            .runnable_ranges(multi_buffer_query_range)
+                            .collect()
+                    }
+                })
+                .await
+            };
+
+            let Ok(multi_buffer_snapshot) =
+                editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx))
+            else {
+                return;
+            };
+            let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| {
+                lsp_tasks
+                    .into_iter()
+                    .flat_map(|(kind, tasks)| {
+                        tasks.into_iter().filter_map(move |(location, task)| {
+                            Some((kind.clone(), location?, task))
+                        })
+                    })
+                    .fold(HashMap::default(), |mut acc, (kind, location, task)| {
+                        let buffer = location.target.buffer;
+                        let buffer_snapshot = buffer.read(cx).snapshot();
+                        let offset = multi_buffer_snapshot.excerpts().find_map(
+                            |(excerpt_id, snapshot, _)| {
+                                if snapshot.remote_id() == buffer_snapshot.remote_id() {
+                                    multi_buffer_snapshot
+                                        .anchor_in_excerpt(excerpt_id, location.target.range.start)
+                                } else {
+                                    None
+                                }
+                            },
+                        );
+                        if let Some(offset) = offset {
+                            let task_buffer_range =
+                                location.target.range.to_point(&buffer_snapshot);
+                            let context_buffer_range =
+                                task_buffer_range.to_offset(&buffer_snapshot);
+                            let context_range = BufferOffset(context_buffer_range.start)
+                                ..BufferOffset(context_buffer_range.end);
+
+                            acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row))
+                                .or_insert_with(|| RunnableTasks {
+                                    templates: Vec::new(),
+                                    offset,
+                                    column: task_buffer_range.start.column,
+                                    extra_variables: HashMap::default(),
+                                    context_range,
+                                })
+                                .templates
+                                .push((kind, task.original_task().clone()));
+                        }
+
+                        acc
+                    })
+            }) else {
+                return;
+            };
+
+            let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
+                buffer.language_settings(cx).tasks.prefer_lsp
+            }) else {
+                return;
+            };
+
+            let rows = Self::runnable_rows(
+                project,
+                multi_buffer_snapshot,
+                prefer_lsp && !lsp_tasks_by_rows.is_empty(),
+                new_rows,
+                cx.clone(),
+            )
+            .await;
+            editor
+                .update(cx, |editor, cx| {
+                    for ((buffer_id, row), mut new_tasks) in rows {
+                        let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
+                            continue;
+                        };
+
+                        if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&(buffer_id, row)) {
+                            new_tasks.templates.extend(lsp_tasks.templates);
+                        }
+                        editor.insert_runnables(
+                            buffer_id,
+                            buffer.read(cx).version(),
+                            row,
+                            new_tasks,
+                        );
+                    }
+                    for ((buffer_id, row), new_tasks) in lsp_tasks_by_rows {
+                        let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
+                            continue;
+                        };
+                        editor.insert_runnables(
+                            buffer_id,
+                            buffer.read(cx).version(),
+                            row,
+                            new_tasks,
+                        );
+                    }
+                })
+                .ok();
+        });
+    }
+
+    pub fn spawn_nearest_task(
+        &mut self,
+        action: &SpawnNearestTask,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some((workspace, _)) = self.workspace.clone() else {
+            return;
+        };
+        let Some(project) = self.project.clone() else {
+            return;
+        };
+
+        // Try to find a closest, enclosing node using tree-sitter that has a task
+        let Some((buffer, buffer_row, tasks)) = self
+            .find_enclosing_node_task(cx)
+            // Or find the task that's closest in row-distance.
+            .or_else(|| self.find_closest_task(cx))
+        else {
+            return;
+        };
+
+        let reveal_strategy = action.reveal;
+        let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
+        cx.spawn_in(window, async move |_, cx| {
+            let context = task_context.await?;
+            let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
+
+            let resolved = &mut resolved_task.resolved;
+            resolved.reveal = reveal_strategy;
+
+            workspace
+                .update_in(cx, |workspace, window, cx| {
+                    workspace.schedule_resolved_task(
+                        task_source_kind,
+                        resolved_task,
+                        false,
+                        window,
+                        cx,
+                    );
+                })
+                .ok()
+        })
+        .detach();
+    }
+
+    pub fn clear_runnables(&mut self, for_buffer: Option<BufferId>) {
+        if let Some(buffer_id) = for_buffer {
+            self.runnables.runnables.remove(&buffer_id);
+        } else {
+            self.runnables.runnables.clear();
+        }
+        self.runnables.runnables_update_task = Task::ready(());
+    }
+
+    pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task<Option<TaskContext>> {
+        let Some(project) = self.project.clone() else {
+            return Task::ready(None);
+        };
+        let (selection, buffer, editor_snapshot) = {
+            let selection = self.selections.newest_adjusted(&self.display_snapshot(cx));
+            let Some((buffer, _)) = self
+                .buffer()
+                .read(cx)
+                .point_to_buffer_offset(selection.start, cx)
+            else {
+                return Task::ready(None);
+            };
+            let snapshot = self.snapshot(window, cx);
+            (selection, buffer, snapshot)
+        };
+        let selection_range = selection.range();
+        let start = editor_snapshot
+            .display_snapshot
+            .buffer_snapshot()
+            .anchor_after(selection_range.start)
+            .text_anchor;
+        let end = editor_snapshot
+            .display_snapshot
+            .buffer_snapshot()
+            .anchor_after(selection_range.end)
+            .text_anchor;
+        let location = Location {
+            buffer,
+            range: start..end,
+        };
+        let captured_variables = {
+            let mut variables = TaskVariables::default();
+            let buffer = location.buffer.read(cx);
+            let buffer_id = buffer.remote_id();
+            let snapshot = buffer.snapshot();
+            let starting_point = location.range.start.to_point(&snapshot);
+            let starting_offset = starting_point.to_offset(&snapshot);
+            for (_, tasks) in self
+                .runnables
+                .runnables
+                .get(&buffer_id)
+                .into_iter()
+                .flat_map(|(_, tasks)| tasks.range(0..starting_point.row + 1))
+            {
+                if !tasks
+                    .context_range
+                    .contains(&crate::BufferOffset(starting_offset))
+                {
+                    continue;
+                }
+                for (capture_name, value) in tasks.extra_variables.iter() {
+                    variables.insert(
+                        VariableName::Custom(capture_name.to_owned().into()),
+                        value.clone(),
+                    );
+                }
+            }
+            variables
+        };
+
+        project.update(cx, |project, cx| {
+            project.task_store().update(cx, |task_store, cx| {
+                task_store.task_context_for_location(captured_variables, location, cx)
+            })
+        })
+    }
+
+    pub fn lsp_task_sources(
+        &self,
+        visible_only: bool,
+        skip_cached: bool,
+        cx: &mut Context<Self>,
+    ) -> HashMap<LanguageServerName, Vec<BufferId>> {
+        if !self.lsp_data_enabled() {
+            return HashMap::default();
+        }
+        let buffers = if visible_only {
+            self.visible_excerpts(true, cx)
+                .into_values()
+                .map(|(buffer, _, _)| buffer)
+                .collect()
+        } else {
+            self.buffer().read(cx).all_buffers()
+        };
+
+        let lsp_settings = &ProjectSettings::get_global(cx).lsp;
+
+        buffers
+            .into_iter()
+            .filter_map(|buffer| {
+                let lsp_tasks_source = buffer
+                    .read(cx)
+                    .language()?
+                    .context_provider()?
+                    .lsp_task_source()?;
+                if lsp_settings
+                    .get(&lsp_tasks_source)
+                    .is_none_or(|s| s.enable_lsp_tasks)
+                {
+                    let buffer_id = buffer.read(cx).remote_id();
+                    if skip_cached
+                        && self
+                            .runnables
+                            .has_cached(buffer_id, &buffer.read(cx).version())
+                    {
+                        None
+                    } else {
+                        Some((lsp_tasks_source, buffer_id))
+                    }
+                } else {
+                    None
+                }
+            })
+            .fold(
+                HashMap::default(),
+                |mut acc, (lsp_task_source, buffer_id)| {
+                    acc.entry(lsp_task_source)
+                        .or_insert_with(Vec::new)
+                        .push(buffer_id);
+                    acc
+                },
+            )
+    }
+
+    pub fn find_enclosing_node_task(
+        &mut self,
+        cx: &mut Context<Self>,
+    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let offset = self
+            .selections
+            .newest::<MultiBufferOffset>(&self.display_snapshot(cx))
+            .head();
+        let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
+        let offset = excerpt.map_offset_to_buffer(offset);
+        let buffer_id = excerpt.buffer().remote_id();
+
+        let layer = excerpt.buffer().syntax_layer_at(offset)?;
+        let mut cursor = layer.node().walk();
+
+        while cursor.goto_first_child_for_byte(offset.0).is_some() {
+            if cursor.node().end_byte() == offset.0 {
+                cursor.goto_next_sibling();
+            }
+        }
+
+        // Ascend to the smallest ancestor that contains the range and has a task.
+        loop {
+            let node = cursor.node();
+            let node_range = node.byte_range();
+            let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
+
+            // Check if this node contains our offset
+            if node_range.start <= offset.0 && node_range.end >= offset.0 {
+                // If it contains offset, check for task
+                if let Some(tasks) = self
+                    .runnables
+                    .runnables
+                    .get(&buffer_id)
+                    .and_then(|(_, tasks)| tasks.get(&symbol_start_row))
+                {
+                    let buffer = self.buffer.read(cx).buffer(buffer_id)?;
+                    return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
+                }
+            }
+
+            if !cursor.goto_parent() {
+                break;
+            }
+        }
+        None
+    }
+
+    pub fn render_run_indicator(
+        &self,
+        _style: &EditorStyle,
+        is_active: bool,
+        row: DisplayRow,
+        breakpoint: Option<(Anchor, Breakpoint, Option<BreakpointSessionState>)>,
+        cx: &mut Context<Self>,
+    ) -> IconButton {
+        let color = Color::Muted;
+        let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor);
+
+        IconButton::new(
+            ("run_indicator", row.0 as usize),
+            ui::IconName::PlayOutlined,
+        )
+        .shape(ui::IconButtonShape::Square)
+        .icon_size(IconSize::XSmall)
+        .icon_color(color)
+        .toggle_state(is_active)
+        .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| {
+            let quick_launch = match e {
+                ClickEvent::Keyboard(_) => true,
+                ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
+            };
+
+            window.focus(&editor.focus_handle(cx), cx);
+            editor.toggle_code_actions(
+                &ToggleCodeActions {
+                    deployed_from: Some(CodeActionSource::RunMenu(row)),
+                    quick_launch,
+                },
+                window,
+                cx,
+            );
+        }))
+        .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
+            editor.set_breakpoint_context_menu(row, position, event.position(), window, cx);
+        }))
+    }
+
+    fn insert_runnables(
+        &mut self,
+        buffer: BufferId,
+        version: Global,
+        row: BufferRow,
+        new_tasks: RunnableTasks,
+    ) {
+        let (old_version, tasks) = self.runnables.runnables.entry(buffer).or_default();
+        if !old_version.changed_since(&version) {
+            *old_version = version;
+            tasks.insert(row, new_tasks);
+        }
+    }
+
+    fn runnable_rows(
+        project: Entity<Project>,
+        snapshot: MultiBufferSnapshot,
+        prefer_lsp: bool,
+        runnable_ranges: Vec<(Range<MultiBufferOffset>, language::RunnableRange)>,
+        cx: AsyncWindowContext,
+    ) -> Task<Vec<((BufferId, BufferRow), RunnableTasks)>> {
+        cx.spawn(async move |cx| {
+            let mut runnable_rows = Vec::with_capacity(runnable_ranges.len());
+            for (run_range, mut runnable) in runnable_ranges {
+                let Some(tasks) = cx
+                    .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
+                    .ok()
+                else {
+                    continue;
+                };
+                let mut tasks = tasks.await;
+
+                if prefer_lsp {
+                    tasks.retain(|(task_kind, _)| {
+                        !matches!(task_kind, TaskSourceKind::Language { .. })
+                    });
+                }
+                if tasks.is_empty() {
+                    continue;
+                }
+
+                let point = run_range.start.to_point(&snapshot);
+                let Some(row) = snapshot
+                    .buffer_line_for_row(MultiBufferRow(point.row))
+                    .map(|(_, range)| range.start.row)
+                else {
+                    continue;
+                };
+
+                let context_range =
+                    BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end);
+                runnable_rows.push((
+                    (runnable.buffer_id, row),
+                    RunnableTasks {
+                        templates: tasks,
+                        offset: snapshot.anchor_before(run_range.start),
+                        context_range,
+                        column: point.column,
+                        extra_variables: runnable.extra_captures,
+                    },
+                ));
+            }
+            runnable_rows
+        })
+    }
+
+    fn templates_with_tags(
+        project: &Entity<Project>,
+        runnable: &mut Runnable,
+        cx: &mut App,
+    ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
+        let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
+            let (worktree_id, file) = project
+                .buffer_for_id(runnable.buffer, cx)
+                .and_then(|buffer| buffer.read(cx).file())
+                .map(|file| (file.worktree_id(cx), file.clone()))
+                .unzip();
+
+            (
+                project.task_store().read(cx).task_inventory().cloned(),
+                worktree_id,
+                file,
+            )
+        });
+
+        let tags = mem::take(&mut runnable.tags);
+        let language = runnable.language.clone();
+        cx.spawn(async move |cx| {
+            let mut templates_with_tags = Vec::new();
+            if let Some(inventory) = inventory {
+                for RunnableTag(tag) in tags {
+                    let new_tasks = inventory.update(cx, |inventory, cx| {
+                        inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx)
+                    });
+                    templates_with_tags.extend(new_tasks.await.into_iter().filter(
+                        move |(_, template)| {
+                            template.tags.iter().any(|source_tag| source_tag == &tag)
+                        },
+                    ));
+                }
+            }
+            templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned());
+
+            if let Some((leading_tag_source, _)) = templates_with_tags.first() {
+                // Strongest source wins; if we have worktree tag binding, prefer that to
+                // global and language bindings;
+                // if we have a global binding, prefer that to language binding.
+                let first_mismatch = templates_with_tags
+                    .iter()
+                    .position(|(tag_source, _)| tag_source != leading_tag_source);
+                if let Some(index) = first_mismatch {
+                    templates_with_tags.truncate(index);
+                }
+            }
+
+            templates_with_tags
+        })
+    }
+
+    fn find_closest_task(
+        &mut self,
+        cx: &mut Context<Self>,
+    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
+        let cursor_row = self
+            .selections
+            .newest_adjusted(&self.display_snapshot(cx))
+            .head()
+            .row;
+
+        let ((buffer_id, row), tasks) = self
+            .runnables
+            .runnables
+            .iter()
+            .flat_map(|(buffer_id, (_, tasks))| {
+                tasks.iter().map(|(row, tasks)| ((*buffer_id, *row), tasks))
+            })
+            .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
+
+        let buffer = self.buffer.read(cx).buffer(buffer_id)?;
+        let tasks = Arc::new(tasks.to_owned());
+        Some((buffer, row, tasks))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{sync::Arc, time::Duration};
+
+    use gpui::{AppContext as _, Task, TestAppContext};
+    use indoc::indoc;
+    use language::ContextProvider;
+    use languages::rust_lang;
+    use multi_buffer::{MultiBuffer, PathKey};
+    use project::{FakeFs, Project};
+    use serde_json::json;
+    use task::{TaskTemplate, TaskTemplates};
+    use text::Point;
+    use util::path;
+
+    use crate::{
+        Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount,
+    };
+
+    struct TestRustContextProvider;
+
+    impl ContextProvider for TestRustContextProvider {
+        fn associated_tasks(
+            &self,
+            _: Option<Arc<dyn language::File>>,
+            _: &gpui::App,
+        ) -> Task<Option<TaskTemplates>> {
+            Task::ready(Some(TaskTemplates(vec![
+                TaskTemplate {
+                    label: "Run main".into(),
+                    command: "cargo".into(),
+                    args: vec!["run".into()],
+                    tags: vec!["rust-main".into()],
+                    ..TaskTemplate::default()
+                },
+                TaskTemplate {
+                    label: "Run test".into(),
+                    command: "cargo".into(),
+                    args: vec!["test".into()],
+                    tags: vec!["rust-test".into()],
+                    ..TaskTemplate::default()
+                },
+            ])))
+        }
+    }
+
+    fn rust_lang_with_task_context() -> Arc<language::Language> {
+        Arc::new(
+            Arc::try_unwrap(rust_lang())
+                .unwrap()
+                .with_context_provider(Some(Arc::new(TestRustContextProvider))),
+        )
+    }
+
+    fn collect_runnable_labels(
+        editor: &Editor,
+    ) -> Vec<(text::BufferId, language::BufferRow, Vec<String>)> {
+        let mut result = editor
+            .runnables
+            .runnables
+            .iter()
+            .flat_map(|(buffer_id, (_, tasks))| {
+                tasks.iter().map(move |(row, runnable_tasks)| {
+                    let mut labels: Vec<String> = runnable_tasks
+                        .templates
+                        .iter()
+                        .map(|(_, template)| template.label.clone())
+                        .collect();
+                    labels.sort();
+                    (*buffer_id, *row, labels)
+                })
+            })
+            .collect::<Vec<_>>();
+        result.sort_by_key(|(id, row, _)| (*id, *row));
+        result
+    }
+
+    #[gpui::test]
+    async fn test_multi_buffer_runnables_on_scroll(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let padding_lines = 50;
+        let mut first_rs = String::from("fn main() {\n    println!(\"hello\");\n}\n");
+        for _ in 0..padding_lines {
+            first_rs.push_str("//\n");
+        }
+        let test_one_row = 3 + padding_lines as u32 + 1;
+        first_rs.push_str("#[test]\nfn test_one() {\n    assert!(true);\n}\n");
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "first.rs": first_rs,
+                "second.rs": indoc! {"
+                    #[test]
+                    fn test_two() {
+                        assert!(true);
+                    }
+
+                    #[test]
+                    fn test_three() {
+                        assert!(true);
+                    }
+                "},
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        language_registry.add(rust_lang_with_task_context());
+
+        let buffer_1 = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/first.rs"), cx)
+            })
+            .await
+            .unwrap();
+        let buffer_2 = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/second.rs"), cx)
+            })
+            .await
+            .unwrap();
+
+        let buffer_1_id = buffer_1.read_with(cx, |buffer, _| buffer.remote_id());
+        let buffer_2_id = buffer_2.read_with(cx, |buffer, _| buffer.remote_id());
+
+        let multi_buffer = cx.new(|cx| {
+            let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite);
+            let end = buffer_1.read(cx).max_point();
+            multi_buffer.set_excerpts_for_path(
+                PathKey::sorted(0),
+                buffer_1.clone(),
+                [Point::new(0, 0)..end],
+                0,
+                cx,
+            );
+            multi_buffer.set_excerpts_for_path(
+                PathKey::sorted(1),
+                buffer_2.clone(),
+                [Point::new(0, 0)..Point::new(8, 1)],
+                0,
+                cx,
+            );
+            multi_buffer
+        });
+
+        let editor = cx.add_window(|window, cx| {
+            Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx)
+        });
+        cx.executor().advance_clock(Duration::from_millis(500));
+        cx.executor().run_until_parked();
+
+        // Clear stale data from startup events, then refresh.
+        // first.rs is long enough that second.rs is below the ~47-line viewport.
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.clear_runnables(None);
+                editor.refresh_runnables(window, cx);
+            })
+            .unwrap();
+        cx.executor().advance_clock(UPDATE_DEBOUNCE);
+        cx.executor().run_until_parked();
+        assert_eq!(
+            editor
+                .update(cx, |editor, _, _| collect_runnable_labels(editor))
+                .unwrap(),
+            vec![(buffer_1_id, 0, vec!["Run main".to_string()])],
+            "Only fn main from first.rs should be visible before scrolling"
+        );
+
+        // Scroll down to bring second.rs excerpts into view.
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx);
+            })
+            .unwrap();
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.executor().run_until_parked();
+
+        let after_scroll = editor
+            .update(cx, |editor, _, _| collect_runnable_labels(editor))
+            .unwrap();
+        assert_eq!(
+            after_scroll,
+            vec![
+                (buffer_1_id, 0, vec!["Run main".to_string()]),
+                (buffer_1_id, test_one_row, vec!["Run test".to_string()]),
+                (buffer_2_id, 1, vec!["Run test".to_string()]),
+                (buffer_2_id, 6, vec!["Run test".to_string()]),
+            ],
+            "Tree-sitter should detect both #[test] fns in second.rs after scroll"
+        );
+
+        // Edit second.rs to invalidate its cache; first.rs data should persist.
+        buffer_2.update(cx, |buffer, cx| {
+            buffer.edit([(0..0, "// added comment\n")], None, cx);
+        });
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.scroll_screen(&ScrollAmount::Page(-1.0), window, cx);
+            })
+            .unwrap();
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.executor().run_until_parked();
+
+        assert_eq!(
+            editor
+                .update(cx, |editor, _, _| collect_runnable_labels(editor))
+                .unwrap(),
+            vec![
+                (buffer_1_id, 0, vec!["Run main".to_string()]),
+                (buffer_1_id, test_one_row, vec!["Run test".to_string()]),
+            ],
+            "first.rs runnables should survive an edit to second.rs"
+        );
+    }
+}

crates/editor/src/semantic_tokens.rs 🔗

@@ -119,7 +119,7 @@ impl Editor {
         for_server: Option<RefreshForServer>,
         cx: &mut Context<Self>,
     ) {
-        if !self.mode().is_full() || !self.semantic_token_state.enabled() {
+        if !self.lsp_data_enabled() || !self.semantic_token_state.enabled() {
             self.invalidate_semantic_tokens(None);
             self.display_map.update(cx, |display_map, _| {
                 match Arc::get_mut(&mut display_map.semantic_token_highlights) {

crates/editor/src/split.rs 🔗

@@ -446,6 +446,9 @@ impl SplittableEditor {
             let mut editor =
                 Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx);
             editor.set_expand_all_diff_hunks(cx);
+            editor.disable_runnables();
+            editor.disable_diagnostics(cx);
+            editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx);
             editor
         });
         // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs

crates/editor/src/tasks.rs 🔗

@@ -1,110 +0,0 @@
-use crate::Editor;
-
-use collections::HashMap;
-use gpui::{App, Task, Window};
-use lsp::LanguageServerName;
-use project::{Location, project_settings::ProjectSettings};
-use settings::Settings as _;
-use task::{TaskContext, TaskVariables, VariableName};
-use text::{BufferId, ToOffset, ToPoint};
-
-impl Editor {
-    pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task<Option<TaskContext>> {
-        let Some(project) = self.project.clone() else {
-            return Task::ready(None);
-        };
-        let (selection, buffer, editor_snapshot) = {
-            let selection = self.selections.newest_adjusted(&self.display_snapshot(cx));
-            let Some((buffer, _)) = self
-                .buffer()
-                .read(cx)
-                .point_to_buffer_offset(selection.start, cx)
-            else {
-                return Task::ready(None);
-            };
-            let snapshot = self.snapshot(window, cx);
-            (selection, buffer, snapshot)
-        };
-        let selection_range = selection.range();
-        let start = editor_snapshot
-            .display_snapshot
-            .buffer_snapshot()
-            .anchor_after(selection_range.start)
-            .text_anchor;
-        let end = editor_snapshot
-            .display_snapshot
-            .buffer_snapshot()
-            .anchor_after(selection_range.end)
-            .text_anchor;
-        let location = Location {
-            buffer,
-            range: start..end,
-        };
-        let captured_variables = {
-            let mut variables = TaskVariables::default();
-            let buffer = location.buffer.read(cx);
-            let buffer_id = buffer.remote_id();
-            let snapshot = buffer.snapshot();
-            let starting_point = location.range.start.to_point(&snapshot);
-            let starting_offset = starting_point.to_offset(&snapshot);
-            for (_, tasks) in self
-                .tasks
-                .range((buffer_id, 0)..(buffer_id, starting_point.row + 1))
-            {
-                if !tasks
-                    .context_range
-                    .contains(&crate::BufferOffset(starting_offset))
-                {
-                    continue;
-                }
-                for (capture_name, value) in tasks.extra_variables.iter() {
-                    variables.insert(
-                        VariableName::Custom(capture_name.to_owned().into()),
-                        value.clone(),
-                    );
-                }
-            }
-            variables
-        };
-
-        project.update(cx, |project, cx| {
-            project.task_store().update(cx, |task_store, cx| {
-                task_store.task_context_for_location(captured_variables, location, cx)
-            })
-        })
-    }
-
-    pub fn lsp_task_sources(&self, cx: &App) -> HashMap<LanguageServerName, Vec<BufferId>> {
-        let lsp_settings = &ProjectSettings::get_global(cx).lsp;
-
-        self.buffer()
-            .read(cx)
-            .all_buffers()
-            .into_iter()
-            .filter_map(|buffer| {
-                let lsp_tasks_source = buffer
-                    .read(cx)
-                    .language()?
-                    .context_provider()?
-                    .lsp_task_source()?;
-                if lsp_settings
-                    .get(&lsp_tasks_source)
-                    .is_none_or(|s| s.enable_lsp_tasks)
-                {
-                    let buffer_id = buffer.read(cx).remote_id();
-                    Some((lsp_tasks_source, buffer_id))
-                } else {
-                    None
-                }
-            })
-            .fold(
-                HashMap::default(),
-                |mut acc, (lsp_task_source, buffer_id)| {
-                    acc.entry(lsp_task_source)
-                        .or_insert_with(Vec::new)
-                        .push(buffer_id);
-                    acc
-                },
-            )
-    }
-}

crates/eval_cli/src/main.rs 🔗

@@ -357,20 +357,16 @@ async fn run_agent(
         Err(e) => return (Err(e), None),
     };
 
-    let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = match NativeAgent::new(
-        project.clone(),
-        thread_store,
-        Templates::new(),
-        None,
-        app_state.fs.clone(),
-        cx,
-    )
-    .await
-    {
-        Ok(a) => a,
-        Err(e) => return (Err(e).context("creating agent"), None),
-    };
+    let agent = cx.update(|cx| {
+        let thread_store = cx.new(|cx| ThreadStore::new(cx));
+        NativeAgent::new(
+            thread_store,
+            Templates::new(),
+            None,
+            app_state.fs.clone(),
+            cx,
+        )
+    });
 
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
     let acp_thread = match cx

crates/feature_flags/src/feature_flags.rs 🔗

@@ -3,12 +3,8 @@ mod flags;
 use std::cell::RefCell;
 use std::rc::Rc;
 use std::sync::LazyLock;
-use std::time::Duration;
-use std::{future::Future, pin::Pin, task::Poll};
 
-use futures::channel::oneshot;
-use futures::{FutureExt, select_biased};
-use gpui::{App, Context, Global, Subscription, Task, Window};
+use gpui::{App, Context, Global, Subscription, Window};
 
 pub use flags::*;
 
@@ -122,11 +118,6 @@ pub struct OnFlagsReady {
 }
 
 pub trait FeatureFlagAppExt {
-    fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag;
-
-    /// Waits for the specified feature flag to resolve, up to the given timeout.
-    fn wait_for_flag_or_timeout<T: FeatureFlag>(&mut self, timeout: Duration) -> Task<bool>;
-
     fn update_flags(&mut self, staff: bool, flags: Vec<String>);
     fn set_staff(&mut self, staff: bool);
     fn has_flag<T: FeatureFlag>(&self) -> bool;
@@ -192,54 +183,4 @@ impl FeatureFlagAppExt for App {
             callback(feature_flags.has_flag::<T>(), cx);
         })
     }
-
-    fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag {
-        let (tx, rx) = oneshot::channel::<bool>();
-        let mut tx = Some(tx);
-        let subscription: Option<Subscription>;
-
-        match self.try_global::<FeatureFlags>() {
-            Some(feature_flags) => {
-                subscription = None;
-                tx.take().unwrap().send(feature_flags.has_flag::<T>()).ok();
-            }
-            None => {
-                subscription = Some(self.observe_global::<FeatureFlags>(move |cx| {
-                    let feature_flags = cx.global::<FeatureFlags>();
-                    if let Some(tx) = tx.take() {
-                        tx.send(feature_flags.has_flag::<T>()).ok();
-                    }
-                }));
-            }
-        }
-
-        WaitForFlag(rx, subscription)
-    }
-
-    fn wait_for_flag_or_timeout<T: FeatureFlag>(&mut self, timeout: Duration) -> Task<bool> {
-        let wait_for_flag = self.wait_for_flag::<T>();
-
-        self.spawn(async move |cx| {
-            let mut wait_for_flag = wait_for_flag.fuse();
-            let mut timeout = FutureExt::fuse(cx.background_executor().timer(timeout));
-
-            select_biased! {
-                is_enabled = wait_for_flag => is_enabled,
-                _ = timeout => false,
-            }
-        })
-    }
-}
-
-pub struct WaitForFlag(oneshot::Receiver<bool>, Option<Subscription>);
-
-impl Future for WaitForFlag {
-    type Output = bool;
-
-    fn poll(mut self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> Poll<Self::Output> {
-        self.0.poll_unpin(cx).map(|result| {
-            self.1.take();
-            result.unwrap_or(false)
-        })
-    }
 }

crates/git_graph/src/git_graph.rs 🔗

@@ -15,7 +15,7 @@ use gpui::{
     px, uniform_list,
 };
 use language::line_diff;
-use menu::{Cancel, SelectNext, SelectPrevious};
+use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::{
     Project,
     git_store::{
@@ -1171,22 +1171,35 @@ impl GitGraph {
         cx.notify();
     }
 
-    fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
+        self.select_entry(0, cx);
+    }
+
+    fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(selected_entry_idx) = &self.selected_entry_idx {
             self.select_entry(selected_entry_idx.saturating_sub(1), cx);
         } else {
-            self.select_entry(0, cx);
+            self.select_first(&SelectFirst, window, cx);
         }
     }
 
     fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(selected_entry_idx) = &self.selected_entry_idx {
-            self.select_entry(selected_entry_idx.saturating_add(1), cx);
+            self.select_entry(
+                selected_entry_idx
+                    .saturating_add(1)
+                    .min(self.graph_data.commits.len().saturating_sub(1)),
+                cx,
+            );
         } else {
             self.select_prev(&SelectPrevious, window, cx);
         }
     }
 
+    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx);
+    }
+
     fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
         self.open_selected_commit_view(window, cx);
     }
@@ -2260,8 +2273,10 @@ impl Render for GitGraph {
                 this.open_selected_commit_view(window, cx);
             }))
             .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::select_first))
             .on_action(cx.listener(Self::select_prev))
             .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_last))
             .on_action(cx.listener(Self::confirm))
             .child(content)
             .children(self.context_menu.as_ref().map(|(menu, position, _)| {

crates/git_ui/src/conflict_view.rs 🔗

@@ -15,7 +15,7 @@ use project::{
     git_store::{GitStoreEvent, RepositoryEvent},
 };
 use settings::Settings;
-use std::{ops::Range, sync::Arc};
+use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
 use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*};
 use util::{ResultExt as _, debug_panic, maybe};
 use workspace::{
@@ -534,7 +534,9 @@ pub(crate) fn register_conflict_notification(
 ) {
     let git_store = workspace.project().read(cx).git_store().clone();
 
-    cx.subscribe(&git_store, |workspace, _git_store, event, cx| {
+    let last_shown_paths: Rc<RefCell<HashSet<String>>> = Rc::new(RefCell::new(HashSet::default()));
+
+    cx.subscribe(&git_store, move |workspace, _git_store, event, cx| {
         let conflicts_changed = matches!(
             event,
             GitStoreEvent::ConflictsUpdated
@@ -546,10 +548,15 @@ pub(crate) fn register_conflict_notification(
 
         let paths = collect_conflicted_file_paths(workspace, cx);
         let notification_id = merge_conflict_notification_id();
+        let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
 
         if paths.is_empty() {
+            last_shown_paths.borrow_mut().clear();
             workspace.dismiss_notification(&notification_id, cx);
-        } else {
+        } else if *last_shown_paths.borrow() != current_paths_set {
+            // Only show the notification if the set of conflicted paths has changed.
+            // This prevents re-showing after the user dismisses it while working on the same conflicts.
+            *last_shown_paths.borrow_mut() = current_paths_set;
             let file_count = paths.len();
             workspace.show_notification(notification_id, cx, |cx| {
                 cx.new(|cx| {
@@ -560,7 +567,7 @@ pub(crate) fn register_conflict_notification(
                     };
 
                     MessageNotification::new(message, cx)
-                        .primary_message("Resolve Conflicts with Agent")
+                        .primary_message("Resolve with Agent")
                         .primary_icon(IconName::ZedAssistant)
                         .primary_icon_color(Color::Muted)
                         .primary_on_click({

crates/language/src/buffer.rs 🔗

@@ -435,7 +435,7 @@ pub enum DiskState {
     /// File created in Zed that has not been saved.
     New,
     /// File present on the filesystem.
-    Present { mtime: MTime },
+    Present { mtime: MTime, size: u64 },
     /// Deleted file that was previously present.
     Deleted,
     /// An old version of a file that was previously present
@@ -448,7 +448,17 @@ impl DiskState {
     pub fn mtime(self) -> Option<MTime> {
         match self {
             DiskState::New => None,
-            DiskState::Present { mtime } => Some(mtime),
+            DiskState::Present { mtime, .. } => Some(mtime),
+            DiskState::Deleted => None,
+            DiskState::Historic { .. } => None,
+        }
+    }
+
+    /// Returns the file's size on disk in bytes.
+    pub fn size(self) -> Option<u64> {
+        match self {
+            DiskState::New => None,
+            DiskState::Present { size, .. } => Some(size),
             DiskState::Deleted => None,
             DiskState::Historic { .. } => None,
         }
@@ -2377,7 +2387,7 @@ impl Buffer {
         };
         match file.disk_state() {
             DiskState::New => false,
-            DiskState::Present { mtime } => match self.saved_mtime {
+            DiskState::Present { mtime, .. } => match self.saved_mtime {
                 Some(saved_mtime) => {
                     mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits()
                 }
@@ -2859,9 +2869,19 @@ impl Buffer {
 
         self.reparse(cx, true);
         cx.emit(BufferEvent::Edited { is_local });
-        if was_dirty != self.is_dirty() {
+        let is_dirty = self.is_dirty();
+        if was_dirty != is_dirty {
             cx.emit(BufferEvent::DirtyChanged);
         }
+        if was_dirty && !is_dirty {
+            if let Some(file) = self.file.as_ref() {
+                if matches!(file.disk_state(), DiskState::Present { .. })
+                    && file.disk_state().mtime() != self.saved_mtime
+                {
+                    cx.emit(BufferEvent::ReloadNeeded);
+                }
+            }
+        }
         cx.notify();
     }
 

crates/languages/src/gitcommit/config.toml 🔗

@@ -7,7 +7,7 @@ path_suffixes = [
   "NOTES_EDITMSG",
   "EDIT_DESCRIPTION",
 ]
-line_comments = ["#"]
+line_comments = ["# "]
 brackets = [
   { start = "(", end = ")", close = true, newline = false },
   { start = "`", end = "`", close = true, newline = false },

crates/languages/src/gomod/config.toml 🔗

@@ -2,7 +2,7 @@ name = "Go Mod"
 code_fence_block_name = "go.mod"
 grammar = "gomod"
 path_suffixes = ["mod"]
-line_comments = ["//"]
+line_comments = ["// "]
 autoclose_before = ")"
 brackets = [
     { start = "(", end = ")", close = true, newline = true}

crates/languages/src/gowork/config.toml 🔗

@@ -2,7 +2,7 @@ name = "Go Work"
 code_fence_block_name = "gowork"
 grammar = "gowork"
 path_suffixes = ["work"]
-line_comments = ["//"]
+line_comments = ["// "]
 autoclose_before = ")"
 brackets = [
     { start = "(", end = ")", close = true, newline = true}

crates/languages/src/python.rs 🔗

@@ -1846,6 +1846,17 @@ impl LspInstaller for PyLspAdapter {
     ) -> Option<LanguageServerBinary> {
         if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await {
             let env = delegate.shell_env().await;
+            delegate
+                .try_exec(LanguageServerBinary {
+                    path: pylsp_bin.clone(),
+                    arguments: vec!["--version".into()],
+                    env: Some(env.clone()),
+                })
+                .await
+                .inspect_err(|err| {
+                    log::warn!("failed to validate user-installed pylsp at {pylsp_bin:?}: {err:#}")
+                })
+                .ok()?;
             Some(LanguageServerBinary {
                 path: pylsp_bin,
                 env: Some(env),
@@ -1854,7 +1865,21 @@ impl LspInstaller for PyLspAdapter {
         } else {
             let toolchain = toolchain?;
             let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp");
-            pylsp_path.exists().then(|| LanguageServerBinary {
+            if !pylsp_path.exists() {
+                return None;
+            }
+            delegate
+                .try_exec(LanguageServerBinary {
+                    path: toolchain.path.to_string().into(),
+                    arguments: vec![pylsp_path.clone().into(), "--version".into()],
+                    env: None,
+                })
+                .await
+                .inspect_err(|err| {
+                    log::warn!("failed to validate toolchain pylsp at {pylsp_path:?}: {err:#}")
+                })
+                .ok()?;
+            Some(LanguageServerBinary {
                 path: toolchain.path.to_string().into(),
                 arguments: vec![pylsp_path.into()],
                 env: None,

crates/livekit_client/src/livekit_client/playback.rs 🔗

@@ -258,7 +258,7 @@ impl AudioStack {
         apm: Arc<Mutex<apm::AudioProcessingModule>>,
         mixer: Arc<Mutex<audio_mixer::AudioMixer>>,
         sample_rate: u32,
-        num_channels: u32,
+        _num_channels: u32,
         output_audio_device: Option<DeviceId>,
     ) -> Result<()> {
         // Prevent App Nap from throttling audio playback on macOS.
@@ -270,6 +270,7 @@ impl AudioStack {
             let mut device_change_listener = DeviceChangeListener::new(false)?;
             let (output_device, output_config) =
                 crate::default_device(false, output_audio_device.as_ref())?;
+            info!("Output config: {output_config:?}");
             let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>();
             let mixer = mixer.clone();
             let apm = apm.clone();
@@ -300,7 +301,12 @@ impl AudioStack {
                                     let sampled = resampler.remix_and_resample(
                                         mixed,
                                         sample_rate / 100,
-                                        num_channels,
+                                        // We need to assume output number of channels as otherwise we will
+                                        // crash in process_reverse_stream otherwise as livekit's audio resampler
+                                        // does not seem to support non-matching channel counts.
+                                        // NOTE: you can verify this by debug printing buf.len() after this stage.
+                                        // For 2->4 channel upmix, we should see buf.len=1920, buf we get only 960.
+                                        output_config.channels() as u32,
                                         sample_rate,
                                         output_config.channels() as u32,
                                         output_config.sample_rate(),

crates/platform_title_bar/src/platform_title_bar.rs 🔗

@@ -31,8 +31,6 @@ pub struct PlatformTitleBar {
     children: SmallVec<[AnyElement; 2]>,
     should_move: bool,
     system_window_tabs: Entity<SystemWindowTabs>,
-    workspace_sidebar_open: bool,
-    sidebar_has_notifications: bool,
 }
 
 impl PlatformTitleBar {
@@ -46,8 +44,6 @@ impl PlatformTitleBar {
             children: SmallVec::new(),
             should_move: false,
             system_window_tabs,
-            workspace_sidebar_open: false,
-            sidebar_has_notifications: false,
         }
     }
 
@@ -74,28 +70,6 @@ impl PlatformTitleBar {
         SystemWindowTabs::init(cx);
     }
 
-    pub fn is_workspace_sidebar_open(&self) -> bool {
-        self.workspace_sidebar_open
-    }
-
-    pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
-        self.workspace_sidebar_open = open;
-        cx.notify();
-    }
-
-    pub fn sidebar_has_notifications(&self) -> bool {
-        self.sidebar_has_notifications
-    }
-
-    pub fn set_sidebar_has_notifications(
-        &mut self,
-        has_notifications: bool,
-        cx: &mut Context<Self>,
-    ) {
-        self.sidebar_has_notifications = has_notifications;
-        cx.notify();
-    }
-
     pub fn is_multi_workspace_enabled(cx: &App) -> bool {
         cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
     }
@@ -110,9 +84,6 @@ impl Render for PlatformTitleBar {
         let close_action = Box::new(workspace::CloseWindow);
         let children = mem::take(&mut self.children);
 
-        let is_multiworkspace_sidebar_open =
-            PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open();
-
         let title_bar = h_flex()
             .window_control_area(WindowControlArea::Drag)
             .w_full()
@@ -161,9 +132,7 @@ impl Render for PlatformTitleBar {
             .map(|this| {
                 if window.is_fullscreen() {
                     this.pl_2()
-                } else if self.platform_style == PlatformStyle::Mac
-                    && !is_multiworkspace_sidebar_open
-                {
+                } else if self.platform_style == PlatformStyle::Mac {
                     this.pl(px(TRAFFIC_LIGHT_PADDING))
                 } else {
                     this.pl_2()
@@ -175,10 +144,9 @@ impl Render for PlatformTitleBar {
                     .when(!(tiling.top || tiling.right), |el| {
                         el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
                     })
-                    .when(
-                        !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open,
-                        |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING),
-                    )
+                    .when(!(tiling.top || tiling.left), |el| {
+                        el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
+                    })
                     // this border is to avoid a transparent gap in the rounded corners
                     .mt(px(-1.))
                     .mb(px(-1.))

crates/project/src/agent_server_store.rs 🔗

@@ -100,7 +100,6 @@ pub trait ExternalAgentServer {
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        status_tx: Option<watch::Sender<SharedString>>,
         new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>>;
@@ -243,7 +242,6 @@ impl AgentServerStore {
                                         project_id: *project_id,
                                         upstream_client: upstream_client.clone(),
                                         name: agent_server_name.clone(),
-                                        status_tx: None,
                                         new_version_available_tx: None,
                                     })
                                         as Box<dyn ExternalAgentServer>,
@@ -347,7 +345,6 @@ impl AgentServerStore {
 
     pub fn init_remote(session: &AnyProtoClient) {
         session.add_entity_message_handler(Self::handle_external_agents_updated);
-        session.add_entity_message_handler(Self::handle_loading_status_updated);
         session.add_entity_message_handler(Self::handle_new_version_available);
     }
 
@@ -695,57 +692,38 @@ impl AgentServerStore {
                     .get_mut(&*envelope.payload.name)
                     .map(|entry| entry.server.as_mut())
                     .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
-                let (status_tx, new_version_available_tx) = downstream_client
-                    .clone()
-                    .map(|(project_id, downstream_client)| {
-                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
-                        let (new_version_available_tx, mut new_version_available_rx) =
-                            watch::channel(None);
-                        cx.spawn({
-                            let downstream_client = downstream_client.clone();
-                            let name = envelope.payload.name.clone();
-                            async move |_, _| {
-                                while let Some(status) = status_rx.recv().await.ok() {
-                                    downstream_client.send(
-                                        proto::ExternalAgentLoadingStatusUpdated {
-                                            project_id,
-                                            name: name.clone(),
-                                            status: status.to_string(),
-                                        },
-                                    )?;
+                let new_version_available_tx =
+                    downstream_client
+                        .clone()
+                        .map(|(project_id, downstream_client)| {
+                            let (new_version_available_tx, mut new_version_available_rx) =
+                                watch::channel(None);
+                            cx.spawn({
+                                let name = envelope.payload.name.clone();
+                                async move |_, _| {
+                                    if let Some(version) =
+                                        new_version_available_rx.recv().await.ok().flatten()
+                                    {
+                                        downstream_client.send(
+                                            proto::NewExternalAgentVersionAvailable {
+                                                project_id,
+                                                name: name.clone(),
+                                                version,
+                                            },
+                                        )?;
+                                    }
+                                    anyhow::Ok(())
                                 }
-                                anyhow::Ok(())
-                            }
-                        })
-                        .detach_and_log_err(cx);
-                        cx.spawn({
-                            let name = envelope.payload.name.clone();
-                            async move |_, _| {
-                                if let Some(version) =
-                                    new_version_available_rx.recv().await.ok().flatten()
-                                {
-                                    downstream_client.send(
-                                        proto::NewExternalAgentVersionAvailable {
-                                            project_id,
-                                            name: name.clone(),
-                                            version,
-                                        },
-                                    )?;
-                                }
-                                anyhow::Ok(())
-                            }
-                        })
-                        .detach_and_log_err(cx);
-                        (status_tx, new_version_available_tx)
-                    })
-                    .unzip();
+                            })
+                            .detach_and_log_err(cx);
+                            new_version_available_tx
+                        });
                 let mut extra_env = HashMap::default();
                 if no_browser {
                     extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned());
                 }
                 anyhow::Ok(agent.get_command(
                     extra_env,
-                    status_tx,
                     new_version_available_tx,
                     &mut cx.to_async(),
                 ))
@@ -782,13 +760,11 @@ impl AgentServerStore {
             };
 
             let mut previous_entries = std::mem::take(&mut this.external_agents);
-            let mut status_txs = HashMap::default();
             let mut new_version_available_txs = HashMap::default();
             let mut metadata = HashMap::default();
 
             for (name, mut entry) in previous_entries.drain() {
                 if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
-                    status_txs.insert(name.clone(), agent.status_tx.take());
                     new_version_available_txs
                         .insert(name.clone(), agent.new_version_available_tx.take());
                 }
@@ -820,7 +796,6 @@ impl AgentServerStore {
                         project_id: *project_id,
                         upstream_client: upstream_client.clone(),
                         name: agent_name.clone(),
-                        status_tx: status_txs.remove(&agent_name).flatten(),
                         new_version_available_tx: new_version_available_txs
                             .remove(&agent_name)
                             .flatten(),
@@ -884,22 +859,6 @@ impl AgentServerStore {
         })
     }
 
-    async fn handle_loading_status_updated(
-        this: Entity<Self>,
-        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
-        mut cx: AsyncApp,
-    ) -> Result<()> {
-        this.update(&mut cx, |this, _| {
-            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
-                && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
-                && let Some(status_tx) = &mut agent.status_tx
-            {
-                status_tx.send(envelope.payload.status.into()).ok();
-            }
-        });
-        Ok(())
-    }
-
     async fn handle_new_version_available(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
@@ -936,7 +895,6 @@ struct RemoteExternalAgentServer {
     project_id: u64,
     upstream_client: Entity<RemoteClient>,
     name: ExternalAgentServerName,
-    status_tx: Option<watch::Sender<SharedString>>,
     new_version_available_tx: Option<watch::Sender<Option<String>>>,
 }
 
@@ -944,14 +902,12 @@ impl ExternalAgentServer for RemoteExternalAgentServer {
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        status_tx: Option<watch::Sender<SharedString>>,
         new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {
         let project_id = self.project_id;
         let name = self.name.to_string();
         let upstream_client = self.upstream_client.downgrade();
-        self.status_tx = status_tx;
         self.new_version_available_tx = new_version_available_tx;
         cx.spawn(async move |cx| {
             let mut response = upstream_client
@@ -1005,7 +961,6 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent {
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        _status_tx: Option<watch::Sender<SharedString>>,
         _new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {
@@ -1205,7 +1160,6 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent {
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        _status_tx: Option<watch::Sender<SharedString>>,
         _new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {
@@ -1386,7 +1340,6 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        _status_tx: Option<watch::Sender<SharedString>>,
         _new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {
@@ -1453,7 +1406,6 @@ impl ExternalAgentServer for LocalCustomAgent {
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        _status_tx: Option<watch::Sender<SharedString>>,
         _new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {

crates/project/src/buffer_store.rs 🔗

@@ -527,7 +527,10 @@ impl LocalBufferStore {
             let new_file = if let Some(entry) = snapshot_entry {
                 File {
                     disk_state: match entry.mtime {
-                        Some(mtime) => DiskState::Present { mtime },
+                        Some(mtime) => DiskState::Present {
+                            mtime,
+                            size: entry.size,
+                        },
                         None => old_file.disk_state,
                     },
                     is_local: true,

crates/project/src/image_store.rs 🔗

@@ -808,7 +808,10 @@ impl LocalImageStore {
             let new_file = if let Some(entry) = snapshot_entry {
                 worktree::File {
                     disk_state: match entry.mtime {
-                        Some(mtime) => DiskState::Present { mtime },
+                        Some(mtime) => DiskState::Present {
+                            mtime,
+                            size: entry.size,
+                        },
                         None => old_file.disk_state,
                     },
                     is_local: true,

crates/project/tests/integration/ext_agent_tests.rs 🔗

@@ -10,7 +10,6 @@ impl ExternalAgentServer for NoopExternalAgent {
     fn get_command(
         &mut self,
         _extra_env: HashMap<String, String>,
-        _status_tx: Option<watch::Sender<SharedString>>,
         _new_version_available_tx: Option<watch::Sender<Option<String>>>,
         _cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {

crates/project/tests/integration/extension_agent_tests.rs 🔗

@@ -26,7 +26,6 @@ impl ExternalAgentServer for NoopExternalAgent {
     fn get_command(
         &mut self,
         _extra_env: HashMap<String, String>,
-        _status_tx: Option<watch::Sender<SharedString>>,
         _new_version_available_tx: Option<watch::Sender<Option<String>>>,
         _cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {

crates/project/tests/integration/project_tests.rs 🔗

@@ -5687,6 +5687,75 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
     cx.update(|cx| assert!(buffer3.read(cx).is_dirty()));
 }
 
+#[gpui::test]
+async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            "file.txt": "version 1",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let buffer = project
+        .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx))
+        .await
+        .unwrap();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.text(), "version 1");
+        assert!(!buffer.is_dirty());
+    });
+
+    // User makes an edit, making the buffer dirty.
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit([(0..0, "user edit: ")], None, cx);
+    });
+
+    buffer.read_with(cx, |buffer, _| {
+        assert!(buffer.is_dirty());
+        assert_eq!(buffer.text(), "user edit: version 1");
+    });
+
+    // External tool writes new content while buffer is dirty.
+    // file_updated() updates the File but suppresses ReloadNeeded.
+    fs.save(
+        path!("/dir/file.txt").as_ref(),
+        &"version 2 from external tool".into(),
+        Default::default(),
+    )
+    .await
+    .unwrap();
+    cx.executor().run_until_parked();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert!(buffer.has_conflict());
+        assert_eq!(buffer.text(), "user edit: version 1");
+    });
+
+    // User undoes their edit. Buffer becomes clean, but disk has different
+    // content. did_edit() detects the dirty->clean transition and checks if
+    // disk changed while dirty. Since mtime differs from saved_mtime, it
+    // emits ReloadNeeded.
+    buffer.update(cx, |buffer, cx| {
+        buffer.undo(cx);
+    });
+    cx.executor().run_until_parked();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            "version 2 from external tool",
+            "buffer should reload from disk after undo makes it clean"
+        );
+        assert!(!buffer.is_dirty());
+    });
+}
+
 #[gpui::test]
 async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
     init_test(cx);

crates/proto/proto/ai.proto 🔗

@@ -222,7 +222,7 @@ message ExternalExtensionAgentsUpdated {
 message ExternalAgentLoadingStatusUpdated {
   uint64 project_id = 1;
   string name = 2;
-  string status = 3;
+  reserved 3;
 }
 
 message NewExternalAgentVersionAvailable {

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1241,8 +1241,8 @@ impl PickerDelegate for RecentProjectsDelegate {
         let focus_handle = self.focus_handle.clone();
         let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
         let open_folder_section = matches!(
-            self.filtered_entries.get(self.selected_index)?,
-            ProjectPickerEntry::OpenFolder { .. }
+            self.filtered_entries.get(self.selected_index),
+            Some(ProjectPickerEntry::OpenFolder { .. })
         );
 
         if popover_style {

crates/settings_content/src/settings_content.rs 🔗

@@ -622,7 +622,7 @@ pub struct GitPanelSettingsContent {
 
     /// Whether to show the addition/deletion change count next to each file in the Git panel.
     ///
-    /// Default: false
+    /// Default: true
     pub diff_stats: Option<bool>,
 }
 

crates/sidebar/Cargo.toml 🔗

@@ -1,50 +0,0 @@
-[package]
-name = "sidebar"
-version = "0.1.0"
-edition.workspace = true
-publish.workspace = true
-license = "GPL-3.0-or-later"
-
-[lints]
-workspace = true
-
-[lib]
-path = "src/sidebar.rs"
-
-[features]
-default = []
-
-[dependencies]
-acp_thread.workspace = true
-agent.workspace = true
-agent-client-protocol.workspace = true
-agent_ui.workspace = true
-chrono.workspace = true
-editor.workspace = true
-feature_flags.workspace = true
-fs.workspace = true
-gpui.workspace = true
-menu.workspace = true
-project.workspace = true
-recent_projects.workspace = true
-settings.workspace = true
-theme.workspace = true
-ui.workspace = true
-util.workspace = true
-workspace.workspace = true
-zed_actions.workspace = true
-
-[dev-dependencies]
-acp_thread = { workspace = true, features = ["test-support"] }
-agent = { workspace = true, features = ["test-support"] }
-agent_ui = { workspace = true, features = ["test-support"] }
-assistant_text_thread = { workspace = true, features = ["test-support"] }
-editor.workspace = true
-language_model = { workspace = true, features = ["test-support"] }
-serde_json.workspace = true
-feature_flags.workspace = true
-fs = { workspace = true, features = ["test-support"] }
-gpui = { workspace = true, features = ["test-support"] }
-project = { workspace = true, features = ["test-support"] }
-settings = { workspace = true, features = ["test-support"] }
-workspace = { workspace = true, features = ["test-support"] }

crates/sqlez/src/connection.rs 🔗

@@ -18,7 +18,7 @@ pub struct Connection {
 unsafe impl Send for Connection {}
 
 impl Connection {
-    pub(crate) fn open(uri: &str, persistent: bool) -> Result<Self> {
+    fn open_with_flags(uri: &str, persistent: bool, flags: i32) -> Result<Self> {
         let mut connection = Self {
             sqlite3: ptr::null_mut(),
             persistent,
@@ -26,7 +26,6 @@ impl Connection {
             _sqlite: PhantomData,
         };
 
-        let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE;
         unsafe {
             sqlite3_open_v2(
                 CString::new(uri)?.as_ptr(),
@@ -44,6 +43,14 @@ impl Connection {
         Ok(connection)
     }
 
+    pub(crate) fn open(uri: &str, persistent: bool) -> Result<Self> {
+        Self::open_with_flags(
+            uri,
+            persistent,
+            SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE,
+        )
+    }
+
     /// Attempts to open the database at uri. If it fails, a shared memory db will be opened
     /// instead.
     pub fn open_file(uri: &str) -> Self {
@@ -51,13 +58,17 @@ impl Connection {
     }
 
     pub fn open_memory(uri: Option<&str>) -> Self {
-        let in_memory_path = if let Some(uri) = uri {
-            format!("file:{}?mode=memory&cache=shared", uri)
+        if let Some(uri) = uri {
+            let in_memory_path = format!("file:{}?mode=memory&cache=shared", uri);
+            return Self::open_with_flags(
+                &in_memory_path,
+                false,
+                SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE | SQLITE_OPEN_URI,
+            )
+            .expect("Could not create fallback in memory db");
         } else {
-            ":memory:".to_string()
-        };
-
-        Self::open(&in_memory_path, false).expect("Could not create fallback in memory db")
+            Self::open(":memory:", false).expect("Could not create fallback in memory db")
+        }
     }
 
     pub fn persistent(&self) -> bool {
@@ -265,9 +276,50 @@ impl Drop for Connection {
 mod test {
     use anyhow::Result;
     use indoc::indoc;
+    use std::{
+        fs,
+        sync::atomic::{AtomicUsize, Ordering},
+    };
 
     use crate::connection::Connection;
 
+    static NEXT_NAMED_MEMORY_DB_ID: AtomicUsize = AtomicUsize::new(0);
+
+    fn unique_named_memory_db(prefix: &str) -> String {
+        format!(
+            "{prefix}_{}_{}",
+            std::process::id(),
+            NEXT_NAMED_MEMORY_DB_ID.fetch_add(1, Ordering::Relaxed)
+        )
+    }
+
+    fn literal_named_memory_paths(name: &str) -> [String; 3] {
+        let main = format!("file:{name}?mode=memory&cache=shared");
+        [main.clone(), format!("{main}-wal"), format!("{main}-shm")]
+    }
+
+    struct NamedMemoryPathGuard {
+        paths: [String; 3],
+    }
+
+    impl NamedMemoryPathGuard {
+        fn new(name: &str) -> Self {
+            let paths = literal_named_memory_paths(name);
+            for path in &paths {
+                let _ = fs::remove_file(path);
+            }
+            Self { paths }
+        }
+    }
+
+    impl Drop for NamedMemoryPathGuard {
+        fn drop(&mut self) {
+            for path in &self.paths {
+                let _ = fs::remove_file(path);
+            }
+        }
+    }
+
     #[test]
     fn string_round_trips() -> Result<()> {
         let connection = Connection::open_memory(Some("string_round_trips"));
@@ -382,6 +434,41 @@ mod test {
         assert_eq!(read_blobs, vec![blob]);
     }
 
+    #[test]
+    fn named_memory_connections_do_not_create_literal_backing_files() {
+        let name = unique_named_memory_db("named_memory_connections_do_not_create_backing_files");
+        let guard = NamedMemoryPathGuard::new(&name);
+
+        let connection1 = Connection::open_memory(Some(&name));
+        connection1
+            .exec(indoc! {"
+                CREATE TABLE shared (
+                    value INTEGER
+                )"})
+            .unwrap()()
+        .unwrap();
+        connection1
+            .exec("INSERT INTO shared (value) VALUES (7)")
+            .unwrap()()
+        .unwrap();
+
+        let connection2 = Connection::open_memory(Some(&name));
+        assert_eq!(
+            connection2
+                .select_row::<i64>("SELECT value FROM shared")
+                .unwrap()()
+            .unwrap(),
+            Some(7)
+        );
+
+        for path in &guard.paths {
+            assert!(
+                fs::metadata(path).is_err(),
+                "named in-memory database unexpectedly created backing file {path}"
+            );
+        }
+    }
+
     #[test]
     fn multi_step_statement_works() {
         let connection = Connection::open_memory(Some("multi_step_statement_works"));

crates/sqlez/src/thread_safe_connection.rs 🔗

@@ -7,12 +7,15 @@ use std::{
     ops::Deref,
     sync::{Arc, LazyLock},
     thread,
+    time::Duration,
 };
 use thread_local::ThreadLocal;
 
 use crate::{connection::Connection, domain::Migrator, util::UnboundedSyncSender};
 
 const MIGRATION_RETRIES: usize = 10;
+const CONNECTION_INITIALIZE_RETRIES: usize = 50;
+const CONNECTION_INITIALIZE_RETRY_DELAY: Duration = Duration::from_millis(1);
 
 type QueuedWrite = Box<dyn 'static + Send + FnOnce()>;
 type WriteQueue = Box<dyn 'static + Send + Sync + Fn(QueuedWrite)>;
@@ -197,21 +200,54 @@ impl ThreadSafeConnection {
             Self::open_shared_memory(uri)
         };
 
+        if let Some(initialize_query) = connection_initialize_query {
+            let mut last_error = None;
+            let initialized = (0..CONNECTION_INITIALIZE_RETRIES).any(|attempt| {
+                match connection
+                    .exec(initialize_query)
+                    .and_then(|mut statement| statement())
+                {
+                    Ok(()) => true,
+                    Err(err)
+                        if is_schema_lock_error(&err)
+                            && attempt + 1 < CONNECTION_INITIALIZE_RETRIES =>
+                    {
+                        last_error = Some(err);
+                        thread::sleep(CONNECTION_INITIALIZE_RETRY_DELAY);
+                        false
+                    }
+                    Err(err) => {
+                        panic!(
+                            "Initialize query failed to execute: {}\n\nCaused by:\n{err:#}",
+                            initialize_query
+                        )
+                    }
+                }
+            });
+
+            if !initialized {
+                let err = last_error
+                    .expect("connection initialization retries should record the last error");
+                panic!(
+                    "Initialize query failed to execute after retries: {}\n\nCaused by:\n{err:#}",
+                    initialize_query
+                );
+            }
+        }
+
         // Disallow writes on the connection. The only writes allowed for thread safe connections
         // are from the background thread that can serialize them.
         *connection.write.get_mut() = false;
 
-        if let Some(initialize_query) = connection_initialize_query {
-            connection.exec(initialize_query).unwrap_or_else(|_| {
-                panic!("Initialize query failed to execute: {}", initialize_query)
-            })()
-            .unwrap()
-        }
-
         connection
     }
 }
 
+fn is_schema_lock_error(err: &anyhow::Error) -> bool {
+    let message = format!("{err:#}");
+    message.contains("database schema is locked") || message.contains("database is locked")
+}
+
 impl ThreadSafeConnection {
     /// Special constructor for ThreadSafeConnection which disallows db initialization and migrations.
     /// This allows construction to be infallible and not write to the db.
@@ -282,7 +318,7 @@ mod test {
     use indoc::indoc;
     use std::ops::Deref;
 
-    use std::thread;
+    use std::{thread, time::Duration};
 
     use crate::{domain::Domain, thread_safe_connection::ThreadSafeConnection};
 
@@ -318,38 +354,21 @@ mod test {
     }
 
     #[test]
-    #[should_panic]
-    fn wild_zed_lost_failure() {
-        enum TestWorkspace {}
-        impl Domain for TestWorkspace {
-            const NAME: &str = "workspace";
-
-            const MIGRATIONS: &[&str] = &["
-                    CREATE TABLE workspaces(
-                        workspace_id INTEGER PRIMARY KEY,
-                        dock_visible INTEGER, -- Boolean
-                        dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded'
-                        dock_pane INTEGER, -- NULL indicates that we don't have a dock pane yet
-                        timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
-                        FOREIGN KEY(dock_pane) REFERENCES panes(pane_id),
-                        FOREIGN KEY(active_pane) REFERENCES panes(pane_id)
-                    ) STRICT;
-
-                    CREATE TABLE panes(
-                        pane_id INTEGER PRIMARY KEY,
-                        workspace_id INTEGER NOT NULL,
-                        active INTEGER NOT NULL, -- Boolean
-                        FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
-                            ON DELETE CASCADE
-                            ON UPDATE CASCADE
-                    ) STRICT;
-                "];
-        }
-
-        let builder =
-            ThreadSafeConnection::builder::<TestWorkspace>("wild_zed_lost_failure", false)
-                .with_connection_initialize_query("PRAGMA FOREIGN_KEYS=true");
-
-        smol::block_on(builder.build()).unwrap();
+    fn connection_initialize_query_retries_transient_schema_lock() {
+        let name = "connection_initialize_query_retries_transient_schema_lock";
+        let locking_connection = crate::connection::Connection::open_memory(Some(name));
+        locking_connection.exec("BEGIN IMMEDIATE").unwrap()().unwrap();
+        locking_connection
+            .exec("CREATE TABLE test(col TEXT)")
+            .unwrap()()
+        .unwrap();
+
+        let releaser = thread::spawn(move || {
+            thread::sleep(Duration::from_millis(10));
+            locking_connection.exec("ROLLBACK").unwrap()().unwrap();
+        });
+
+        ThreadSafeConnection::create_connection(false, name, Some("PRAGMA FOREIGN_KEYS=true"));
+        releaser.join().unwrap();
     }
 }

crates/tasks_ui/src/tasks_ui.rs 🔗

@@ -316,7 +316,9 @@ pub fn task_contexts(
 
     let lsp_task_sources = active_editor
         .as_ref()
-        .map(|active_editor| active_editor.update(cx, |editor, cx| editor.lsp_task_sources(cx)))
+        .map(|active_editor| {
+            active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx))
+        })
         .unwrap_or_default();
 
     let latest_selection = active_editor.as_ref().map(|active_editor| {

crates/title_bar/Cargo.toml 🔗

@@ -38,7 +38,6 @@ chrono.workspace = true
 client.workspace = true
 cloud_api_types.workspace = true
 db.workspace = true
-feature_flags.workspace = true
 git_ui.workspace = true
 gpui = { workspace = true, features = ["screen-capture"] }
 notifications.workspace = true

crates/title_bar/src/title_bar.rs 🔗

@@ -24,16 +24,13 @@ use auto_update::AutoUpdateStatus;
 use call::ActiveCall;
 use client::{Client, UserStore, zed_urls};
 use cloud_api_types::Plan;
-use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
     Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
     StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div,
 };
 use onboarding_banner::OnboardingBanner;
-use project::{
-    DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
-};
+use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
 use remote::RemoteConnectionOptions;
 use settings::Settings;
 use settings::WorktreeId;
@@ -47,8 +44,7 @@ use ui::{
 use update_version::UpdateVersion;
 use util::ResultExt;
 use workspace::{
-    MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
-    notifications::NotifyResultExt,
+    MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
 };
 use zed_actions::OpenRemote;
 
@@ -174,7 +170,6 @@ impl Render for TitleBar {
                     let mut render_project_items = title_bar_settings.show_branch_name
                         || title_bar_settings.show_project_items;
                     title_bar
-                        .children(self.render_workspace_sidebar_toggle(window, cx))
                         .when_some(
                             self.application_menu.clone().filter(|_| !show_menus),
                             |title_bar, menu| {
@@ -357,7 +352,6 @@ impl TitleBar {
 
         // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar.
         {
-            let platform_titlebar = platform_titlebar.clone();
             let window_handle = window.window_handle();
             cx.spawn(async move |this: WeakEntity<TitleBar>, cx| {
                 let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
@@ -370,26 +364,8 @@ impl TitleBar {
                         return;
                     };
 
-                    let is_open = multi_workspace.read(cx).is_sidebar_open();
-                    let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx);
-                    platform_titlebar.update(cx, |titlebar, cx| {
-                        titlebar.set_workspace_sidebar_open(is_open, cx);
-                        titlebar.set_sidebar_has_notifications(has_notifications, cx);
-                    });
-
-                    let platform_titlebar = platform_titlebar.clone();
-                    let subscription = cx.observe(&multi_workspace, move |mw, cx| {
-                        let is_open = mw.read(cx).is_sidebar_open();
-                        let has_notifications = mw.read(cx).sidebar_has_notifications(cx);
-                        platform_titlebar.update(cx, |titlebar, cx| {
-                            titlebar.set_workspace_sidebar_open(is_open, cx);
-                            titlebar.set_sidebar_has_notifications(has_notifications, cx);
-                        });
-                    });
-
                     if let Some(this) = this.upgrade() {
                         this.update(cx, |this, _| {
-                            this._subscriptions.push(subscription);
                             this.multi_workspace = Some(multi_workspace.downgrade());
                         });
                     }
@@ -686,46 +662,7 @@ impl TitleBar {
         )
     }
 
-    fn render_workspace_sidebar_toggle(
-        &self,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Option<AnyElement> {
-        if !cx.has_flag::<AgentV2FeatureFlag>() || DisableAiSettings::get_global(cx).disable_ai {
-            return None;
-        }
-
-        let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
-
-        if is_sidebar_open {
-            return None;
-        }
-
-        let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
-
-        Some(
-            IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
-                .icon_size(IconSize::Small)
-                .when(has_notifications, |button| {
-                    button
-                        .indicator(Indicator::dot().color(Color::Accent))
-                        .indicator_border_color(Some(cx.theme().colors().title_bar_background))
-                })
-                .tooltip(move |_, cx| {
-                    Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
-                })
-                .on_click(|_, window, cx| {
-                    window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
-                })
-                .into_any_element(),
-        )
-    }
-
-    pub fn render_project_name(
-        &self,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
+    pub fn render_project_name(&self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let workspace = self.workspace.clone();
 
         let name = self.effective_active_worktree(cx).map(|worktree| {
@@ -741,19 +678,6 @@ impl TitleBar {
             "Open Recent Project".to_string()
         };
 
-        let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
-
-        if is_sidebar_open {
-            return self
-                .render_project_name_with_sidebar_popover(
-                    window,
-                    display_name,
-                    is_project_selected,
-                    cx,
-                )
-                .into_any_element();
-        }
-
         let focus_handle = workspace
             .upgrade()
             .map(|w| w.read(cx).focus_handle(cx))
@@ -793,49 +717,6 @@ impl TitleBar {
             .into_any_element()
     }
 
-    fn render_project_name_with_sidebar_popover(
-        &self,
-        _window: &Window,
-        display_name: String,
-        is_project_selected: bool,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
-        let multi_workspace = self.multi_workspace.clone();
-
-        let is_popover_deployed = multi_workspace
-            .as_ref()
-            .and_then(|mw| mw.upgrade())
-            .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx))
-            .unwrap_or(false);
-
-        Button::new("project_name_trigger", display_name)
-            .label_size(LabelSize::Small)
-            .when(self.worktree_count(cx) > 1, |this| {
-                this.icon(IconName::ChevronDown)
-                    .icon_color(Color::Muted)
-                    .icon_size(IconSize::XSmall)
-            })
-            .toggle_state(is_popover_deployed)
-            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-            .when(!is_project_selected, |s| s.color(Color::Muted))
-            .tooltip(move |_window, cx| {
-                Tooltip::for_action(
-                    "Recent Projects",
-                    &zed_actions::OpenRecent {
-                        create_new_window: false,
-                    },
-                    cx,
-                )
-            })
-            .on_click(move |_, window, cx| {
-                if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) {
-                    mw.update(cx, |mw, cx| {
-                        mw.toggle_recent_projects_popover(window, cx);
-                    });
-                }
-            })
-    }
-
     pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
         let effective_worktree = self.effective_active_worktree(cx)?;
         let repository = self.get_repository_for_worktree(&effective_worktree, cx)?;

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

@@ -227,6 +227,12 @@ impl RenderOnce for ThreadItem {
                 .gradient_stop(0.8)
                 .group_name("thread-item");
 
+        let has_diff_stats = self.added.is_some() || self.removed.is_some();
+        let added_count = self.added.unwrap_or(0);
+        let removed_count = self.removed.unwrap_or(0);
+        let diff_stat_id = self.id.clone();
+        let has_worktree = self.worktree.is_some();
+
         v_flex()
             .id(self.id.clone())
             .group("thread-item")
@@ -235,7 +241,7 @@ impl RenderOnce for ThreadItem {
             .cursor_pointer()
             .w_full()
             .map(|this| {
-                if self.worktree.is_some() {
+                if has_worktree || has_diff_stats {
                     this.p_2()
                 } else {
                     this.px_2().py_1()
@@ -300,35 +306,24 @@ impl RenderOnce for ThreadItem {
                         .gap_1p5()
                         .child(icon_container()) // Icon Spacing
                         .child(worktree_label)
-                        // TODO: Uncomment the elements below when we're ready to expose this data
-                        // .child(dot_separator())
-                        // .child(
-                        //     Label::new(self.timestamp)
-                        //         .size(LabelSize::Small)
-                        //         .color(Color::Muted),
-                        // )
-                        // .child(
-                        //     Label::new("•")
-                        //         .size(LabelSize::Small)
-                        //         .color(Color::Muted)
-                        //         .alpha(0.5),
-                        // )
-                        // .when(has_no_changes, |this| {
-                        //     this.child(
-                        //         Label::new("No Changes")
-                        //             .size(LabelSize::Small)
-                        //             .color(Color::Muted),
-                        //     )
-                        // })
-                        .when(self.added.is_some() || self.removed.is_some(), |this| {
+                        .when(has_diff_stats, |this| {
                             this.child(DiffStat::new(
-                                self.id,
-                                self.added.unwrap_or(0),
-                                self.removed.unwrap_or(0),
+                                diff_stat_id.clone(),
+                                added_count,
+                                removed_count,
                             ))
                         }),
                 )
             })
+            .when(!has_worktree && has_diff_stats, |this| {
+                this.child(
+                    h_flex()
+                        .min_w_0()
+                        .gap_1p5()
+                        .child(icon_container()) // Icon Spacing
+                        .child(DiffStat::new(diff_stat_id, added_count, removed_count)),
+                )
+            })
             .when_some(self.on_click, |this, on_click| this.on_click(on_click))
     }
 }

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

@@ -31,6 +31,9 @@ pub struct ListItem {
     /// A slot for content that appears on hover after the children
     /// It will obscure the `end_slot` when visible.
     end_hover_slot: Option<AnyElement>,
+    /// When true, renders a gradient fade overlay before the `end_hover_slot`
+    /// to smoothly truncate overflowing content.
+    end_hover_gradient_overlay: bool,
     toggle: Option<bool>,
     inset: bool,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -60,6 +63,7 @@ impl ListItem {
             start_slot: None,
             end_slot: None,
             end_hover_slot: None,
+            end_hover_gradient_overlay: false,
             toggle: None,
             inset: false,
             on_click: None,
@@ -166,6 +170,11 @@ impl ListItem {
         self
     }
 
+    pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self {
+        self.end_hover_gradient_overlay = show;
+        self
+    }
+
     pub fn outlined(mut self) -> Self {
         self.outlined = true;
         self
@@ -362,7 +371,9 @@ impl RenderOnce for ListItem {
                                 .right(DynamicSpacing::Base06.rems(cx))
                                 .top_0()
                                 .visible_on_hover("list_item")
-                                .child(end_hover_gradient_overlay)
+                                .when(self.end_hover_gradient_overlay, |this| {
+                                    this.child(end_hover_gradient_overlay)
+                                })
                                 .child(end_hover_slot),
                         )
                     }),

crates/workspace/src/multi_workspace.rs 🔗

@@ -1,9 +1,8 @@
 use anyhow::Result;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
-    AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
-    ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
-    actions, deferred, px,
+    App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render,
+    Subscription, Task, Tiling, Window, WindowId, actions, px,
 };
 use project::{DisableAiSettings, Project};
 use settings::Settings;
@@ -12,11 +11,12 @@ use std::path::PathBuf;
 use ui::prelude::*;
 use util::ResultExt;
 
-const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
 
 use crate::{
     CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast,
     Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId,
+    persistence::model::MultiWorkspaceId,
 };
 
 actions!(
@@ -41,31 +41,6 @@ pub enum MultiWorkspaceEvent {
     WorkspaceRemoved(EntityId),
 }
 
-pub enum SidebarEvent {
-    Open,
-    Close,
-}
-
-pub trait Sidebar: EventEmitter<SidebarEvent> + Focusable + Render + Sized {
-    fn width(&self, cx: &App) -> Pixels;
-    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
-    fn has_notifications(&self, cx: &App) -> bool;
-    fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
-    fn is_recent_projects_popover_deployed(&self) -> bool;
-}
-
-pub trait SidebarHandle: 'static + Send + Sync {
-    fn width(&self, cx: &App) -> Pixels;
-    fn set_width(&self, width: Option<Pixels>, cx: &mut App);
-    fn focus_handle(&self, cx: &App) -> FocusHandle;
-    fn focus(&self, window: &mut Window, cx: &mut App);
-    fn has_notifications(&self, cx: &App) -> bool;
-    fn to_any(&self) -> AnyView;
-    fn entity_id(&self) -> EntityId;
-    fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
-    fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool;
-}
-
 #[derive(Clone)]
 pub struct DraggedSidebar;
 
@@ -75,54 +50,11 @@ impl Render for DraggedSidebar {
     }
 }
 
-impl<T: Sidebar> SidebarHandle for Entity<T> {
-    fn width(&self, cx: &App) -> Pixels {
-        self.read(cx).width(cx)
-    }
-
-    fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
-        self.update(cx, |this, cx| this.set_width(width, cx))
-    }
-
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.read(cx).focus_handle(cx)
-    }
-
-    fn focus(&self, window: &mut Window, cx: &mut App) {
-        let handle = self.read(cx).focus_handle(cx);
-        window.focus(&handle, cx);
-    }
-
-    fn has_notifications(&self, cx: &App) -> bool {
-        self.read(cx).has_notifications(cx)
-    }
-
-    fn to_any(&self) -> AnyView {
-        self.clone().into()
-    }
-
-    fn entity_id(&self) -> EntityId {
-        Entity::entity_id(self)
-    }
-
-    fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
-        self.update(cx, |this, cx| {
-            this.toggle_recent_projects_popover(window, cx);
-        });
-    }
-
-    fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
-        self.read(cx).is_recent_projects_popover_deployed()
-    }
-}
-
 pub struct MultiWorkspace {
     window_id: WindowId,
     workspaces: Vec<Entity<Workspace>>,
+    database_id: Option<MultiWorkspaceId>,
     active_workspace_index: usize,
-    sidebar: Option<Box<dyn SidebarHandle>>,
-    sidebar_open: bool,
-    _sidebar_subscription: Option<Subscription>,
     pending_removal_tasks: Vec<Task<()>>,
     _serialize_task: Option<Task<()>>,
     _create_task: Option<Task<()>>,
@@ -131,6 +63,10 @@ pub struct MultiWorkspace {
 
 impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
 
+pub fn multi_workspace_enabled(cx: &App) -> bool {
+    cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
+}
+
 impl MultiWorkspace {
     pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
@@ -145,142 +81,17 @@ impl MultiWorkspace {
             }
         });
         let quit_subscription = cx.on_app_quit(Self::app_will_quit);
-        let settings_subscription =
-            cx.observe_global_in::<settings::SettingsStore>(window, |this, window, cx| {
-                if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open {
-                    this.close_sidebar(window, cx);
-                }
-            });
         Self::subscribe_to_workspace(&workspace, cx);
         Self {
             window_id: window.window_handle().window_id(),
+            database_id: None,
             workspaces: vec![workspace],
             active_workspace_index: 0,
-            sidebar: None,
-            sidebar_open: false,
-            _sidebar_subscription: None,
             pending_removal_tasks: Vec::new(),
             _serialize_task: None,
             _create_task: None,
-            _subscriptions: vec![
-                release_subscription,
-                quit_subscription,
-                settings_subscription,
-            ],
-        }
-    }
-
-    pub fn register_sidebar<T: Sidebar>(
-        &mut self,
-        sidebar: Entity<T>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let subscription =
-            cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
-                SidebarEvent::Open => this.toggle_sidebar(window, cx),
-                SidebarEvent::Close => {
-                    this.close_sidebar(window, cx);
-                }
-            });
-        self.sidebar = Some(Box::new(sidebar));
-        self._sidebar_subscription = Some(subscription);
-    }
-
-    pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
-        self.sidebar.as_deref()
-    }
-
-    pub fn sidebar_open(&self) -> bool {
-        self.sidebar_open && self.sidebar.is_some()
-    }
-
-    pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
-        self.sidebar
-            .as_ref()
-            .map_or(false, |s| s.has_notifications(cx))
-    }
-
-    pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
-        if let Some(sidebar) = &self.sidebar {
-            sidebar.toggle_recent_projects_popover(window, cx);
-        }
-    }
-
-    pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
-        self.sidebar
-            .as_ref()
-            .map_or(false, |s| s.is_recent_projects_popover_deployed(cx))
-    }
-
-    pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
-        cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
-    }
-
-    pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
-            return;
-        }
-
-        if self.sidebar_open {
-            self.close_sidebar(window, cx);
-        } else {
-            self.open_sidebar(cx);
-            if let Some(sidebar) = &self.sidebar {
-                sidebar.focus(window, cx);
-            }
-        }
-    }
-
-    pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
-            return;
-        }
-
-        if self.sidebar_open {
-            let sidebar_is_focused = self
-                .sidebar
-                .as_ref()
-                .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
-
-            if sidebar_is_focused {
-                let pane = self.workspace().read(cx).active_pane().clone();
-                let pane_focus = pane.read(cx).focus_handle(cx);
-                window.focus(&pane_focus, cx);
-            } else if let Some(sidebar) = &self.sidebar {
-                sidebar.focus(window, cx);
-            }
-        } else {
-            self.open_sidebar(cx);
-            if let Some(sidebar) = &self.sidebar {
-                sidebar.focus(window, cx);
-            }
-        }
-    }
-
-    pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
-        self.sidebar_open = true;
-        for workspace in &self.workspaces {
-            workspace.update(cx, |workspace, cx| {
-                workspace.set_workspace_sidebar_open(true, cx);
-            });
-        }
-        self.serialize(cx);
-        cx.notify();
-    }
-
-    fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.sidebar_open = false;
-        for workspace in &self.workspaces {
-            workspace.update(cx, |workspace, cx| {
-                workspace.set_workspace_sidebar_open(false, cx);
-            });
+            _subscriptions: vec![release_subscription, quit_subscription],
         }
-        let pane = self.workspace().read(cx).active_pane().clone();
-        let pane_focus = pane.read(cx).focus_handle(cx);
-        window.focus(&pane_focus, cx);
-        self.serialize(cx);
-        cx.notify();
     }
 
     pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
@@ -318,10 +129,6 @@ impl MultiWorkspace {
         .detach();
     }
 
-    pub fn is_sidebar_open(&self) -> bool {
-        self.sidebar_open
-    }
-
     pub fn workspace(&self) -> &Entity<Workspace> {
         &self.workspaces[self.active_workspace_index]
     }
@@ -335,7 +142,7 @@ impl MultiWorkspace {
     }
 
     pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
+        if !multi_workspace_enabled(cx) {
             self.workspaces[0] = workspace;
             self.active_workspace_index = 0;
             cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
@@ -371,11 +178,6 @@ impl MultiWorkspace {
         if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
             index
         } else {
-            if self.sidebar_open {
-                workspace.update(cx, |workspace, cx| {
-                    workspace.set_workspace_sidebar_open(true, cx);
-                });
-            }
             Self::subscribe_to_workspace(&workspace, cx);
             self.workspaces.push(workspace.clone());
             cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
@@ -384,6 +186,14 @@ impl MultiWorkspace {
         }
     }
 
+    pub fn database_id(&self) -> Option<MultiWorkspaceId> {
+        self.database_id
+    }
+
+    pub fn set_database_id(&mut self, id: Option<MultiWorkspaceId>) {
+        self.database_id = id;
+    }
+
     pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
         debug_assert!(
             index < self.workspaces.len(),
@@ -421,7 +231,6 @@ impl MultiWorkspace {
         let window_id = self.window_id;
         let state = crate::persistence::model::MultiWorkspaceState {
             active_workspace_id: self.workspace().read(cx).database_id(),
-            sidebar_open: self.sidebar_open,
         };
         self._serialize_task = Some(cx.background_spawn(async move {
             crate::persistence::write_multi_workspace_state(window_id, state).await;
@@ -540,7 +349,7 @@ impl MultiWorkspace {
         self.workspace().read(cx).items_of_type::<T>(cx)
     }
 
-    pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
+    pub fn active_workspace_database_id(&self, cx: &App) -> Option<WorkspaceId> {
         self.workspace().read(cx).database_id()
     }
 
@@ -583,7 +392,7 @@ impl MultiWorkspace {
     }
 
     pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
+        if !multi_workspace_enabled(cx) {
             return;
         }
         let app_state = self.workspace().read(cx).app_state().clone();
@@ -692,7 +501,7 @@ impl MultiWorkspace {
     ) -> Task<Result<()>> {
         let workspace = self.workspace().clone();
 
-        if self.multi_workspace_enabled(cx) {
+        if multi_workspace_enabled(cx) {
             workspace.update(cx, |workspace, cx| {
                 workspace.open_workspace_for_paths(true, paths, window, cx)
             })
@@ -719,57 +528,6 @@ impl MultiWorkspace {
 
 impl Render for MultiWorkspace {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let multi_workspace_enabled = self.multi_workspace_enabled(cx);
-
-        let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open {
-            self.sidebar.as_ref().map(|sidebar_handle| {
-                let weak = cx.weak_entity();
-
-                let sidebar_width = sidebar_handle.width(cx);
-                let resize_handle = deferred(
-                    div()
-                        .id("sidebar-resize-handle")
-                        .absolute()
-                        .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
-                        .top(px(0.))
-                        .h_full()
-                        .w(SIDEBAR_RESIZE_HANDLE_SIZE)
-                        .cursor_col_resize()
-                        .on_drag(DraggedSidebar, |dragged, _, _, cx| {
-                            cx.stop_propagation();
-                            cx.new(|_| dragged.clone())
-                        })
-                        .on_mouse_down(MouseButton::Left, |_, _, cx| {
-                            cx.stop_propagation();
-                        })
-                        .on_mouse_up(MouseButton::Left, move |event, _, cx| {
-                            if event.click_count == 2 {
-                                weak.update(cx, |this, cx| {
-                                    if let Some(sidebar) = this.sidebar.as_mut() {
-                                        sidebar.set_width(None, cx);
-                                    }
-                                })
-                                .ok();
-                                cx.stop_propagation();
-                            }
-                        })
-                        .occlude(),
-                );
-
-                div()
-                    .id("sidebar-container")
-                    .relative()
-                    .h_full()
-                    .w(sidebar_width)
-                    .flex_shrink_0()
-                    .child(sidebar_handle.to_any())
-                    .child(resize_handle)
-                    .into_any_element()
-            })
-        } else {
-            None
-        };
-
         let ui_font = theme::setup_ui_font(window, cx);
         let text_color = cx.theme().colors().text;
 
@@ -799,32 +557,6 @@ impl Render for MultiWorkspace {
                         this.activate_previous_workspace(window, cx);
                     },
                 ))
-                .when(self.multi_workspace_enabled(cx), |this| {
-                    this.on_action(cx.listener(
-                        |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
-                            this.toggle_sidebar(window, cx);
-                        },
-                    ))
-                    .on_action(cx.listener(
-                        |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
-                            this.focus_sidebar(window, cx);
-                        },
-                    ))
-                })
-                .when(
-                    self.sidebar_open() && self.multi_workspace_enabled(cx),
-                    |this| {
-                        this.on_drag_move(cx.listener(
-                            |this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
-                                if let Some(sidebar) = &this.sidebar {
-                                    let new_width = e.event.position.x;
-                                    sidebar.set_width(Some(new_width), cx);
-                                }
-                            },
-                        ))
-                        .children(sidebar)
-                    },
-                )
                 .child(
                     div()
                         .flex()
@@ -837,98 +569,9 @@ impl Render for MultiWorkspace {
             window,
             cx,
             Tiling {
-                left: multi_workspace_enabled && self.sidebar_open,
+                left: false,
                 ..Tiling::default()
             },
         )
     }
 }
-
-#[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::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.is_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.is_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.is_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.is_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.is_sidebar_open(),
-                "Sidebar should open when toggled after re-enabling AI"
-            );
-        });
-    }
-}

crates/workspace/src/notifications.rs 🔗

@@ -657,15 +657,17 @@ impl RenderOnce for NotificationFrame {
                                 IconButton::new(close_id, close_icon)
                                     .tooltip(move |_window, cx| {
                                         if suppress {
-                                            Tooltip::for_action(
-                                                "Suppress.\nClose with click.",
-                                                &SuppressNotification,
+                                            Tooltip::with_meta(
+                                                "Suppress",
+                                                Some(&SuppressNotification),
+                                                "Click to Close",
                                                 cx,
                                             )
                                         } else if show_suppress_button {
-                                            Tooltip::for_action(
-                                                "Close.\nSuppress with shift-click.",
-                                                &menu::Cancel,
+                                            Tooltip::with_meta(
+                                                "Close",
+                                                Some(&menu::Cancel),
+                                                "Shift-click to Suppress",
                                                 cx,
                                             )
                                         } else {

crates/workspace/src/persistence.rs 🔗

@@ -341,6 +341,7 @@ pub fn read_serialized_multi_workspaces(
                 .map(read_multi_workspace_state)
                 .unwrap_or_default();
             model::SerializedMultiWorkspace {
+                id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())),
                 workspaces: group,
                 state,
             }
@@ -3877,7 +3878,6 @@ mod tests {
             window_10,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(2)),
-                sidebar_open: true,
             },
         )
         .await;
@@ -3886,7 +3886,6 @@ mod tests {
             window_20,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(3)),
-                sidebar_open: false,
             },
         )
         .await;
@@ -3924,23 +3923,20 @@ mod tests {
         // Should produce 3 groups: window 10, window 20, and the orphan.
         assert_eq!(results.len(), 3);
 
-        // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open.
+        // Window 10 group: 2 workspaces, active_workspace_id = 2.
         let group_10 = &results[0];
         assert_eq!(group_10.workspaces.len(), 2);
         assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
-        assert_eq!(group_10.state.sidebar_open, true);
 
-        // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed.
+        // Window 20 group: 1 workspace, active_workspace_id = 3.
         let group_20 = &results[1];
         assert_eq!(group_20.workspaces.len(), 1);
         assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
-        assert_eq!(group_20.state.sidebar_open, false);
 
         // Orphan group: no window_id, so state is default.
         let group_none = &results[2];
         assert_eq!(group_none.workspaces.len(), 1);
         assert_eq!(group_none.state.active_workspace_id, None);
-        assert_eq!(group_none.state.sidebar_open, false);
     }
 
     #[gpui::test]

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

@@ -63,18 +63,19 @@ pub struct SessionWorkspace {
 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
 pub struct MultiWorkspaceState {
     pub active_workspace_id: Option<WorkspaceId>,
-    pub sidebar_open: bool,
 }
 
-/// The serialized state of a single MultiWorkspace window from a previous session:
-/// all workspaces that shared the window, which one was active, and whether the
-/// sidebar was open.
+/// The serialized state of a single MultiWorkspace window from a previous session.
 #[derive(Debug, Clone)]
 pub struct SerializedMultiWorkspace {
+    pub id: Option<MultiWorkspaceId>,
     pub workspaces: Vec<SessionWorkspace>,
     pub state: MultiWorkspaceState,
 }
 
+#[derive(Debug, Clone, Copy)]
+pub struct MultiWorkspaceId(pub u64);
+
 #[derive(Debug, PartialEq, Clone)]
 pub(crate) struct SerializedWorkspace {
     pub(crate) id: WorkspaceId,

crates/workspace/src/status_bar.rs 🔗

@@ -34,7 +34,6 @@ pub struct StatusBar {
     right_items: Vec<Box<dyn StatusItemViewHandle>>,
     active_pane: Entity<Pane>,
     _observe_active_pane: Subscription,
-    workspace_sidebar_open: bool,
 }
 
 impl Render for StatusBar {
@@ -52,10 +51,9 @@ impl Render for StatusBar {
                     .when(!(tiling.bottom || tiling.right), |el| {
                         el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
                     })
-                    .when(
-                        !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open,
-                        |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING),
-                    )
+                    .when(!(tiling.bottom || tiling.left), |el| {
+                        el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
+                    })
                     // This border is to avoid a transparent gap in the rounded corners
                     .mb(px(-1.))
                     .border_b(px(1.0))
@@ -91,17 +89,11 @@ impl StatusBar {
             _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| {
                 this.update_active_pane_item(window, cx)
             }),
-            workspace_sidebar_open: false,
         };
         this.update_active_pane_item(window, cx);
         this
     }
 
-    pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
-        self.workspace_sidebar_open = open;
-        cx.notify();
-    }
-
     pub fn add_left_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
     where
         T: 'static + StatusItemView,

crates/workspace/src/workspace.rs 🔗

@@ -28,8 +28,8 @@ pub use crate::notifications::NotificationFrame;
 pub use dock::Panel;
 pub use multi_workspace::{
     DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
-    NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent,
-    SidebarHandle, ToggleWorkspaceSidebar,
+    NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow,
+    SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled,
 };
 pub use path_list::{PathList, SerializedPathList};
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
     model::{
-        DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
-        SessionWorkspace,
+        DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace,
+        SerializedWorkspaceLocation, SessionWorkspace,
     },
     read_serialized_multi_workspaces,
 };
@@ -2154,12 +2154,6 @@ impl Workspace {
         &self.status_bar
     }
 
-    pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) {
-        self.status_bar.update(cx, |status_bar, cx| {
-            status_bar.set_workspace_sidebar_open(open, cx);
-        });
-    }
-
     pub fn status_bar_visible(&self, cx: &App) -> bool {
         StatusBarSettings::get_global(cx).show
     }
@@ -8184,7 +8178,11 @@ pub async fn restore_multiworkspace(
     app_state: Arc<AppState>,
     cx: &mut AsyncApp,
 ) -> anyhow::Result<MultiWorkspaceRestoreResult> {
-    let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
+    let SerializedMultiWorkspace {
+        workspaces,
+        state,
+        id: window_id,
+    } = multi_workspace;
     let mut group_iter = workspaces.into_iter();
     let first = group_iter
         .next()
@@ -8248,6 +8246,7 @@ pub async fn restore_multiworkspace(
     if let Some(target_id) = state.active_workspace_id {
         window_handle
             .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.set_database_id(window_id);
                 let target_index = multi_workspace
                     .workspaces()
                     .iter()
@@ -8269,14 +8268,6 @@ pub async fn restore_multiworkspace(
             .ok();
     }
 
-    if state.sidebar_open {
-        window_handle
-            .update(cx, |multi_workspace, _, cx| {
-                multi_workspace.open_sidebar(cx);
-            })
-            .ok();
-    }
-
     window_handle
         .update(cx, |_, window, _cx| {
             window.activate_window();

crates/worktree/src/worktree.rs 🔗

@@ -1322,6 +1322,7 @@ impl LocalWorktree {
                         path,
                         disk_state: DiskState::Present {
                             mtime: metadata.mtime,
+                            size: metadata.len,
                         },
                         is_local: true,
                         is_private,
@@ -1378,6 +1379,7 @@ impl LocalWorktree {
                         path,
                         disk_state: DiskState::Present {
                             mtime: metadata.mtime,
+                            size: metadata.len,
                         },
                         is_local: true,
                         is_private,
@@ -1575,6 +1577,7 @@ impl LocalWorktree {
                     path,
                     disk_state: DiskState::Present {
                         mtime: metadata.mtime,
+                        size: metadata.len,
                     },
                     entry_id: None,
                     is_local: true,
@@ -3289,7 +3292,10 @@ impl File {
             worktree,
             path: entry.path.clone(),
             disk_state: if let Some(mtime) = entry.mtime {
-                DiskState::Present { mtime }
+                DiskState::Present {
+                    mtime,
+                    size: entry.size,
+                }
             } else {
                 DiskState::New
             },
@@ -3318,7 +3324,7 @@ impl File {
         } else if proto.is_deleted {
             DiskState::Deleted
         } else if let Some(mtime) = proto.mtime.map(&Into::into) {
-            DiskState::Present { mtime }
+            DiskState::Present { mtime, size: 0 }
         } else {
             DiskState::New
         };

crates/zed/Cargo.toml 🔗

@@ -2,7 +2,7 @@
 description = "The fast, collaborative code editor."
 edition.workspace = true
 name = "zed"
-version = "0.228.0"
+version = "0.229.0"
 publish.workspace = true
 license = "GPL-3.0-or-later"
 authors = ["Zed Team <hi@zed.dev>"]
@@ -182,7 +182,6 @@ settings.workspace = true
 settings_profile_selector.workspace = true
 settings_ui.workspace = true
 shellexpand.workspace = true
-sidebar.workspace = true
 smol.workspace = true
 snippet_provider.workspace = true
 snippets_ui.workspace = true

crates/zed/src/visual_test_runner.rs 🔗

@@ -103,8 +103,8 @@ use {
     feature_flags::FeatureFlagAppExt as _,
     git_ui::project_diff::ProjectDiff,
     gpui::{
-        App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext,
-        WindowBounds, WindowHandle, WindowOptions, point, px, size,
+        Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString,
+        VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size,
     },
     image::RgbaImage,
     project_panel::ProjectPanel,
@@ -2649,22 +2649,6 @@ fn run_multi_workspace_sidebar_visual_tests(
 
     cx.run_until_parked();
 
-    // Create the sidebar and register it on the MultiWorkspace
-    let sidebar = multi_workspace_window
-        .update(cx, |_multi_workspace, window, cx| {
-            let multi_workspace_handle = cx.entity();
-            cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
-        })
-        .context("Failed to create sidebar")?;
-
-    multi_workspace_window
-        .update(cx, |multi_workspace, window, cx| {
-            multi_workspace.register_sidebar(sidebar.clone(), window, cx);
-        })
-        .context("Failed to register sidebar")?;
-
-    cx.run_until_parked();
-
     // Save test threads to the ThreadStore for each workspace
     let save_tasks = multi_workspace_window
         .update(cx, |multi_workspace, _window, cx| {
@@ -2742,8 +2726,8 @@ fn run_multi_workspace_sidebar_visual_tests(
 
     // Open the sidebar
     multi_workspace_window
-        .update(cx, |multi_workspace, window, cx| {
-            multi_workspace.toggle_sidebar(window, cx);
+        .update(cx, |_multi_workspace, window, cx| {
+            window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
         })
         .context("Failed to toggle sidebar")?;
 
@@ -3181,24 +3165,10 @@ edition = "2021"
 
     cx.run_until_parked();
 
-    // Create and register the workspace sidebar
-    let sidebar = workspace_window
-        .update(cx, |_multi_workspace, window, cx| {
-            let multi_workspace_handle = cx.entity();
-            cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
-        })
-        .context("Failed to create sidebar")?;
-
-    workspace_window
-        .update(cx, |multi_workspace, window, cx| {
-            multi_workspace.register_sidebar(sidebar.clone(), window, cx);
-        })
-        .context("Failed to register sidebar")?;
-
     // Open the sidebar
     workspace_window
-        .update(cx, |multi_workspace, window, cx| {
-            multi_workspace.toggle_sidebar(window, cx);
+        .update(cx, |_multi_workspace, window, cx| {
+            window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
         })
         .context("Failed to toggle sidebar")?;
 

crates/zed/src/zed.rs 🔗

@@ -68,7 +68,6 @@ use settings::{
     initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
     update_settings_file,
 };
-use sidebar::Sidebar;
 use std::time::Duration;
 use std::{
     borrow::Cow,
@@ -163,21 +162,24 @@ pub fn init(cx: &mut App) {
     cx.on_action(quit);
 
     cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
-    let flag = cx.wait_for_flag::<PanicFeatureFlag>();
-    cx.spawn(async |cx| {
-        if cx.update(|cx| ReleaseChannel::global(cx) == ReleaseChannel::Dev) || flag.await {
-            cx.update(|cx| {
-                cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
-                    .on_action(|_: &TestCrash, _| {
-                        unsafe extern "C" {
-                            fn puts(s: *const i8);
-                        }
-                        unsafe {
-                            puts(0xabad1d3a as *const i8);
-                        }
-                    });
-            });
-        };
+
+    cx.observe_flag::<PanicFeatureFlag, _>({
+        let mut added = false;
+        move |enabled, cx| {
+            if added || !enabled {
+                return;
+            }
+            added = true;
+            cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
+                .on_action(|_: &TestCrash, _| {
+                    unsafe extern "C" {
+                        fn puts(s: *const i8);
+                    }
+                    unsafe {
+                        puts(0xabad1d3a as *const i8);
+                    }
+                });
+        }
     })
     .detach();
     cx.on_action(|_: &OpenLog, cx| {
@@ -386,20 +388,6 @@ pub fn initialize_workspace(
                 })
                 .unwrap_or(true)
         });
-
-        let window_handle = window.window_handle();
-        let multi_workspace_handle = cx.entity();
-        cx.defer(move |cx| {
-            window_handle
-                .update(cx, |_, window, cx| {
-                    let sidebar =
-                        cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx));
-                    multi_workspace_handle.update(cx, |multi_workspace, cx| {
-                        multi_workspace.register_sidebar(sidebar, window, cx);
-                    });
-                })
-                .ok();
-        });
     })
     .detach();
 

docs/theme/css/chrome.css 🔗

@@ -368,7 +368,10 @@ mark.fade-out {
 .searchbar-outer {
   margin-inline-start: auto;
   margin-inline-end: auto;
+  width: 100%;
   max-width: var(--content-max-width);
+  box-sizing: border-box;
+  padding: 16px;
 }
 
 #searchbar {
@@ -394,21 +397,21 @@ mark.fade-out {
 .searchresults-header {
   font-weight: bold;
   font-size: 1em;
-  padding-block-start: 18px;
+  padding-block-start: 0;
   padding-block-end: 0;
-  padding-inline-start: 5px;
-  padding-inline-end: 0;
   color: var(--searchresults-header-fg);
 }
 
 ul#searchresults {
   list-style: none;
   padding-inline-start: 0;
+  margin-block-end: 0;
 }
 ul#searchresults li {
   margin: 10px 0px;
   padding: 2px;
   border-radius: 2px;
+  scroll-margin-block-end: 10px;
 }
 ul#searchresults li.focus {
   background-color: var(--searchresults-li-bg);
@@ -794,8 +797,7 @@ ul#searchresults span.teaser em {
   max-height: 600px;
   display: flex;
   flex-direction: column;
-  padding: 16px;
-  overflow-y: auto;
+  overflow-y: hidden;
 
   border-radius: 8px;
   background: var(--popover-bg);
@@ -803,8 +805,11 @@ ul#searchresults span.teaser em {
   box-shadow: var(--popover-shadow);
 }
 
-.searchbar-outer {
-  width: 100%;
+.searchresults-outer {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  padding: 0px 22px 22px 22px;
 }
 
 #searchbar {

docs/theme/index.hbs 🔗

@@ -424,6 +424,31 @@
         <script src="{{ path_to_root }}elasticlunr.min.js"></script>
         <script src="{{ path_to_root }}mark.min.js"></script>
         <script src="{{ path_to_root }}searcher.js"></script>
+
+        <script>
+           (function () {
+                // Check for focused search result and bring into the view
+                const ensureVisible = () => {
+                    const focused = document.querySelector("#searchresults li.focus");
+
+                    if (focused) {
+                        focused.scrollIntoView({
+                            block: "nearest",
+                            inline: "nearest"
+                        });
+                    }
+                };
+
+                // 1. Listen for arrow key events
+                // 2. Wait for DOM to update
+                // 3. Call envsureVisible
+                document.addEventListener("keydown", function (e) {
+                    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+                        requestAnimationFrame(ensureVisible);
+                    }
+                });
+            })();
+        </script>
         {{/if}}
 
         <script src="{{ path_to_root }}clipboard.min.js"></script>

script/danger/dangerfile.ts 🔗

@@ -61,6 +61,25 @@ if (includesIssueUrl) {
   );
 }
 
+const MIGRATION_SCHEMA_FILES = [
+  "crates/collab/migrations/20251208000000_test_schema.sql",
+  "crates/collab/migrations.sqlite/20221109000000_test_schema.sql",
+];
+
+const modifiedSchemaFiles = danger.git.modified_files.filter((file) =>
+  MIGRATION_SCHEMA_FILES.some((schemaFilePath) => file.endsWith(schemaFilePath)),
+);
+
+if (modifiedSchemaFiles.length > 0) {
+  warn(
+    [
+      "This PR modifies database schema files.",
+      "",
+      "If you are making database changes, a migration needs to be added in the Cloud repository.",
+    ].join("\n"),
+  );
+}
+
 const FIXTURE_CHANGE_ATTESTATION = "Changes to test fixtures are intentional and necessary.";
 
 const FIXTURES_PATHS = ["crates/assistant_tools/src/edit_agent/evals/fixtures"];

tooling/xtask/src/tasks/workflows.rs 🔗

@@ -29,38 +29,99 @@ mod runners;
 mod steps;
 mod vars;
 
+#[derive(Clone)]
+pub(crate) struct GitSha(String);
+
+impl AsRef<str> for GitSha {
+    fn as_ref(&self) -> &str {
+        &self.0
+    }
+}
+
+#[allow(
+    clippy::disallowed_methods,
+    reason = "This runs only in a CLI environment"
+)]
+fn parse_ref(value: &str) -> Result<GitSha, String> {
+    const GIT_SHA_LENGTH: usize = 40;
+    (value.len() == GIT_SHA_LENGTH)
+        .then_some(value)
+        .ok_or_else(|| {
+            format!(
+                "Git SHA has wrong length! \
+                Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.",
+                len = value.len()
+            )
+        })
+        .and_then(|value| {
+            let mut tmp = [0; 4];
+            value
+                .chars()
+                .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value)
+                .ok_or_else(|| "Not a valid Git SHA".to_owned())
+        })
+        .and_then(|sha| {
+           std::process::Command::new("git")
+               .args([
+                   "rev-parse",
+                   "--quiet",
+                   "--verify",
+                   &format!("{sha}^{{commit}}")
+               ])
+               .output()
+               .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned())
+               .and_then(|output|
+                   output
+                       .status.success()
+                       .then_some(sha)
+                       .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!")))
+        }).map(|sha| GitSha(sha.to_owned()))
+}
+
 #[derive(Parser)]
-pub struct GenerateWorkflowArgs {}
+pub(crate) struct GenerateWorkflowArgs {
+    #[arg(value_parser = parse_ref)]
+    /// The Git SHA to use when invoking this
+    pub(crate) sha: Option<GitSha>,
+}
+
+enum WorkflowSource {
+    Contextless(fn() -> Workflow),
+    WithContext(fn(&GenerateWorkflowArgs) -> Workflow),
+}
 
 struct WorkflowFile {
-    source: fn() -> Workflow,
+    source: WorkflowSource,
     r#type: WorkflowType,
 }
 
 impl WorkflowFile {
     fn zed(f: fn() -> Workflow) -> WorkflowFile {
         WorkflowFile {
-            source: f,
+            source: WorkflowSource::Contextless(f),
             r#type: WorkflowType::Zed,
         }
     }
 
-    fn extension(f: fn() -> Workflow) -> WorkflowFile {
+    fn extension(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile {
         WorkflowFile {
-            source: f,
+            source: WorkflowSource::WithContext(f),
             r#type: WorkflowType::ExtensionCi,
         }
     }
 
-    fn extension_shared(f: fn() -> Workflow) -> WorkflowFile {
+    fn extension_shared(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile {
         WorkflowFile {
-            source: f,
+            source: WorkflowSource::WithContext(f),
             r#type: WorkflowType::ExtensionsShared,
         }
     }
 
-    fn generate_file(&self) -> Result<()> {
-        let workflow = (self.source)();
+    fn generate_file(&self, workflow_args: &GenerateWorkflowArgs) -> Result<()> {
+        let workflow = match &self.source {
+            WorkflowSource::Contextless(f) => f(),
+            WorkflowSource::WithContext(f) => f(workflow_args),
+        };
         let workflow_folder = self.r#type.folder_path();
 
         fs::create_dir_all(&workflow_folder).with_context(|| {
@@ -124,7 +185,7 @@ impl WorkflowType {
     }
 }
 
-pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> {
+pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
     if !Path::new("crates/zed/").is_dir() {
         anyhow::bail!("xtask workflows must be ran from the project root");
     }
@@ -154,7 +215,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> {
     ];
 
     for workflow_file in workflows {
-        workflow_file.generate_file()?;
+        workflow_file.generate_file(&args)?;
     }
 
     workflow_checks::validate(Default::default())

tooling/xtask/src/tasks/workflows/deploy_collab.rs 🔗

@@ -3,7 +3,7 @@ use indoc::indoc;
 
 use crate::tasks::workflows::runners::{self, Platform};
 use crate::tasks::workflows::steps::{
-    self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named,
+    self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang,
 };
 use crate::tasks::workflows::vars;
 
@@ -23,7 +23,7 @@ pub(crate) fn deploy_collab() -> Workflow {
 }
 
 fn style() -> NamedJob {
-    named::job(
+    named::job(use_clang(
         dependant_job(&[])
             .name("Check formatting and Clippy lints")
             .with_repository_owner_guard()
@@ -34,7 +34,7 @@ fn style() -> NamedJob {
             .map(steps::install_linux_dependencies)
             .add_step(steps::cargo_fmt())
             .add_step(steps::clippy(Platform::Linux)),
-    )
+    ))
 }
 
 fn tests(deps: &[&NamedJob]) -> NamedJob {
@@ -42,7 +42,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob {
         named::bash("cargo nextest run --package collab --no-fail-fast")
     }
 
-    named::job(
+    named::job(use_clang(
         dependant_job(deps)
             .name("Run tests")
             .runs_on(runners::LINUX_XL)
@@ -65,7 +65,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob {
             .add_step(steps::cargo_install_nextest())
             .add_step(steps::clear_target_dir_if_large(Platform::Linux))
             .add_step(run_collab_tests()),
-    )
+    ))
 }
 
 fn publish(deps: &[&NamedJob]) -> NamedJob {

tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs 🔗

@@ -6,46 +6,72 @@ use indoc::indoc;
 use serde_json::json;
 
 use crate::tasks::workflows::steps::CheckoutStep;
+use crate::tasks::workflows::steps::cache_rust_dependencies_namespace;
+use crate::tasks::workflows::vars::JobOutput;
 use crate::tasks::workflows::{
     extension_bump::{RepositoryTarget, generate_token},
     runners,
     steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
-    vars::{self, StepOutput},
+    vars::{self, StepOutput, WorkflowInput},
 };
 
 const ROLLOUT_TAG_NAME: &str = "extension-workflows";
+const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files";
 
 pub(crate) fn extension_workflow_rollout() -> Workflow {
-    let fetch_repos = fetch_extension_repos();
-    let rollout_workflows = rollout_workflows_to_extension(&fetch_repos);
-    let create_tag = create_rollout_tag(&rollout_workflows);
+    let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new()))
+        .description(
+            "Comma-separated list of repository names to rollout to. Leave empty for all repos.",
+        );
+    let extra_context_input = WorkflowInput::string("change-description", Some(String::new()))
+        .description("Description for the changes to be expected with this rollout");
+
+    let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input);
+    let rollout_workflows = rollout_workflows_to_extension(
+        &fetch_repos,
+        removed_ci,
+        removed_shared,
+        &extra_context_input,
+    );
+    let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input);
 
     named::workflow()
-        .on(Event::default().workflow_dispatch(WorkflowDispatch::default()))
+        .on(Event::default().workflow_dispatch(
+            WorkflowDispatch::default()
+                .add_input(filter_repos_input.name, filter_repos_input.input())
+                .add_input(extra_context_input.name, extra_context_input.input()),
+        ))
         .add_env(("CARGO_TERM_COLOR", "always"))
         .add_job(fetch_repos.name, fetch_repos.job)
         .add_job(rollout_workflows.name, rollout_workflows.job)
         .add_job(create_tag.name, create_tag.job)
 }
 
-fn fetch_extension_repos() -> NamedJob {
-    fn get_repositories() -> (Step<Use>, StepOutput) {
+fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) {
+    fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step<Use>, StepOutput) {
         let step = named::uses("actions", "github-script", "v7")
             .id("list-repos")
             .add_with((
                 "script",
-                indoc::indoc! {r#"
-                    const repos = await github.paginate(github.rest.repos.listForOrg, {
+                formatdoc! {r#"
+                    const repos = await github.paginate(github.rest.repos.listForOrg, {{
                         org: 'zed-extensions',
                         type: 'public',
                         per_page: 100,
-                    });
+                    }});
 
-                    const filteredRepos = repos
+                    let filteredRepos = repos
                         .filter(repo => !repo.archived)
                         .map(repo => repo.name);
 
-                    console.log(`Found ${filteredRepos.length} extension repos`);
+                    const filterInput = `{filter_repos_input}`.trim();
+                    if (filterInput.length > 0) {{
+                        const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
+                        filteredRepos = filteredRepos.filter(name => allowedNames.includes(name));
+                        console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`);
+                    }}
+
+                    console.log(`Found ${{filteredRepos.length}} extension repos`);
                     return filteredRepos;
                 "#},
             ))
@@ -56,36 +82,12 @@ fn fetch_extension_repos() -> NamedJob {
         (step, filtered_repos)
     }
 
-    let (get_org_repositories, list_repos_output) = get_repositories();
-
-    let job = Job::default()
-        .cond(Expression::new(format!(
-            "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
-        )))
-        .runs_on(runners::LINUX_SMALL)
-        .timeout_minutes(5u32)
-        .outputs([("repos".to_owned(), list_repos_output.to_string())])
-        .add_step(get_org_repositories);
-
-    named::job(job)
-}
-
-fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
     fn checkout_zed_repo() -> CheckoutStep {
         steps::checkout_repo()
             .with_full_history()
-            .with_path("zed")
             .with_custom_name("checkout_zed_repo")
     }
 
-    fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
-        steps::checkout_repo()
-            .with_custom_name("checkout_extension_repo")
-            .with_token(token)
-            .with_repository("zed-extensions/${{ matrix.repo }}")
-            .with_path("extension")
-    }
-
     fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
         let step = named::bash(formatdoc! {r#"
             PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
@@ -96,49 +98,126 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
             echo "Found previous rollout at commit: $PREV_COMMIT"
             echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
         "#})
-        .id("prev-tag")
-        .working_directory("zed");
+        .id("prev-tag");
 
         let step_output = StepOutput::new(&step, "prev_commit");
 
         (step, step_output)
     }
 
-    fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput) {
-        let step = named::bash(indoc::indoc! {r#"
-            if [ "$MATRIX_REPO" = "workflows" ]; then
-                WORKFLOW_DIR="extensions/workflows"
-            else
-                WORKFLOW_DIR="extensions/workflows/shared"
-            fi
-
-            echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
+    fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput, StepOutput) {
+        let step = named::bash(indoc! {r#"
+            for workflow_type in "ci" "shared"; do
+                if [ "$workflow_type" = "ci" ]; then
+                    WORKFLOW_DIR="extensions/workflows"
+                else
+                    WORKFLOW_DIR="extensions/workflows/shared"
+                fi
+
+                REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
+                    awk '/^D/ { print $2 } /^R/ { print $2 }' | \
+                    xargs -I{} basename {} 2>/dev/null | \
+                    tr '\n' ' ' || echo "")
+                REMOVED=$(echo "$REMOVED" | xargs)
+
+                echo "Removed files for $workflow_type: $REMOVED"
+                echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT"
+            done
+        "#})
+        .id("calc-changes")
+        .add_env(("PREV_COMMIT", prev_commit.to_string()));
 
-            # Get deleted files (status D) and renamed files (status R - old name needs removal)
-            # Using -M to detect renames, then extracting files that are gone from their original location
-            REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
-                awk '/^D/ { print $2 } /^R/ { print $2 }' | \
-                xargs -I{} basename {} 2>/dev/null | \
-                tr '\n' ' ' || echo "")
+        let removed_ci = StepOutput::new(&step, "removed_ci");
+        let removed_shared = StepOutput::new(&step, "removed_shared");
 
-            REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
+        (step, removed_ci, removed_shared)
+    }
 
-            echo "Files to remove: $REMOVED_FILES"
-            echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
+    fn generate_workflow_files() -> Step<Run> {
+        named::bash(indoc! {r#"
+            cargo xtask workflows "$COMMIT_SHA"
         "#})
-        .id("calc-changes")
-        .working_directory("zed")
-        .add_env(("PREV_COMMIT", prev_commit.to_string()))
-        .add_env(("MATRIX_REPO", "${{ matrix.repo }}"));
+        .add_env(("COMMIT_SHA", "${{ github.sha }}"))
+    }
 
-        let removed_files = StepOutput::new(&step, "removed_files");
+    fn upload_workflow_files() -> Step<Use> {
+        named::uses(
+            "actions",
+            "upload-artifact",
+            "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
+        )
+        .add_with(("name", WORKFLOW_ARTIFACT_NAME))
+        .add_with(("path", "extensions/workflows/**/*.yml"))
+        .add_with(("if-no-files-found", "error"))
+    }
 
-        (step, removed_files)
+    let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input);
+    let (get_prev_tag, prev_commit) = get_previous_tag_commit();
+    let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit);
+
+    let job = Job::default()
+        .cond(Expression::new(format!(
+            "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
+        )))
+        .runs_on(runners::LINUX_SMALL)
+        .timeout_minutes(10u32)
+        .outputs([
+            ("repos".to_owned(), list_repos_output.to_string()),
+            ("prev_commit".to_owned(), prev_commit.to_string()),
+            ("removed_ci".to_owned(), removed_ci.to_string()),
+            ("removed_shared".to_owned(), removed_shared.to_string()),
+        ])
+        .add_step(checkout_zed_repo())
+        .add_step(get_prev_tag)
+        .add_step(calc_changes)
+        .add_step(get_org_repositories)
+        .add_step(cache_rust_dependencies_namespace())
+        .add_step(generate_workflow_files())
+        .add_step(upload_workflow_files());
+
+    let job = named::job(job);
+    let (removed_ci, removed_shared) = (
+        removed_ci.as_job_output(&job),
+        removed_shared.as_job_output(&job),
+    );
+
+    (job, removed_ci, removed_shared)
+}
+
+fn rollout_workflows_to_extension(
+    fetch_repos_job: &NamedJob,
+    removed_ci: JobOutput,
+    removed_shared: JobOutput,
+    extra_context_input: &WorkflowInput,
+) -> NamedJob {
+    fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
+        steps::checkout_repo()
+            .with_custom_name("checkout_extension_repo")
+            .with_token(token)
+            .with_repository("zed-extensions/${{ matrix.repo }}")
+            .with_path("extension")
+    }
+
+    fn download_workflow_files() -> Step<Use> {
+        named::uses(
+            "actions",
+            "download-artifact",
+            "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
+        )
+        .add_with(("name", WORKFLOW_ARTIFACT_NAME))
+        .add_with(("path", "workflow-files"))
     }
 
-    fn sync_workflow_files(removed_files: &StepOutput) -> Step<Run> {
-        named::bash(indoc::indoc! {r#"
+    fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step<Run> {
+        named::bash(indoc! {r#"
             mkdir -p extension/.github/workflows
+
+            if [ "$MATRIX_REPO" = "workflows" ]; then
+                REMOVED_FILES="$REMOVED_CI"
+            else
+                REMOVED_FILES="$REMOVED_SHARED"
+            fi
+
             cd extension/.github/workflows
 
             if [ -n "$REMOVED_FILES" ]; then
@@ -152,40 +231,46 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
             cd - > /dev/null
 
             if [ "$MATRIX_REPO" = "workflows" ]; then
-                cp zed/extensions/workflows/*.yml extension/.github/workflows/
+                cp workflow-files/*.yml extension/.github/workflows/
             else
-                cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
+                cp workflow-files/shared/*.yml extension/.github/workflows/
             fi
         "#})
-        .add_env(("REMOVED_FILES", removed_files.to_string()))
+        .add_env(("REMOVED_CI", removed_ci))
+        .add_env(("REMOVED_SHARED", removed_shared))
         .add_env(("MATRIX_REPO", "${{ matrix.repo }}"))
     }
 
     fn get_short_sha() -> (Step<Run>, StepOutput) {
-        let step = named::bash(indoc::indoc! {r#"
-            echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
+        let step = named::bash(indoc! {r#"
+            echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
         "#})
-        .id("short-sha")
-        .working_directory("zed");
+        .id("short-sha");
 
         let step_output = StepOutput::new(&step, "sha_short");
 
         (step, step_output)
     }
 
-    fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step<Use> {
+    fn create_pull_request(
+        token: &StepOutput,
+        short_sha: &StepOutput,
+        context_input: &WorkflowInput,
+    ) -> Step<Use> {
         let title = format!("Update CI workflows to `{short_sha}`");
 
+        let body = formatdoc! {r#"
+            This PR updates the CI workflow files from the main Zed repository
+            based on the commit zed-industries/zed@${{{{ github.sha }}}}
+
+            {context_input}
+        "#,
+        };
+
         named::uses("peter-evans", "create-pull-request", "v7")
             .add_with(("path", "extension"))
             .add_with(("title", title.clone()))
-            .add_with((
-                "body",
-                indoc::indoc! {r#"
-                    This PR updates the CI workflow files from the main Zed repository
-                    based on the commit zed-industries/zed@${{ github.sha }}
-                "#},
-            ))
+            .add_with(("body", body))
             .add_with(("commit-message", title))
             .add_with(("branch", "update-workflows"))
             .add_with((
@@ -204,12 +289,12 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
     }
 
     fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
-        named::bash(indoc::indoc! {r#"
+        named::bash(indoc! {r#"
             if [ -n "$PR_NUMBER" ]; then
-                cd extension
                 gh pr merge "$PR_NUMBER" --auto --squash
             fi
         "#})
+        .working_directory("extension")
         .add_env(("GH_TOKEN", token.to_string()))
         .add_env((
             "PR_NUMBER",
@@ -228,8 +313,6 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
             ]),
         ),
     );
-    let (get_prev_tag, prev_commit) = get_previous_tag_commit();
-    let (calc_changes, removed_files) = get_removed_files(&prev_commit);
     let (calculate_short_sha, short_sha) = get_short_sha();
 
     let job = Job::default()
@@ -249,19 +332,17 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
                 })),
         )
         .add_step(authenticate)
-        .add_step(checkout_zed_repo())
         .add_step(checkout_extension_repo(&token))
-        .add_step(get_prev_tag)
-        .add_step(calc_changes)
-        .add_step(sync_workflow_files(&removed_files))
+        .add_step(download_workflow_files())
+        .add_step(sync_workflow_files(removed_ci, removed_shared))
         .add_step(calculate_short_sha)
-        .add_step(create_pull_request(&token, &short_sha))
+        .add_step(create_pull_request(&token, &short_sha, extra_context_input))
         .add_step(enable_auto_merge(&token));
 
     named::job(job)
 }
 
-fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob {
+fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob {
     fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
         steps::checkout_repo().with_full_history().with_token(token)
     }
@@ -297,6 +378,10 @@ fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob {
 
     let job = Job::default()
         .needs([rollout_job.name.clone()])
+        .cond(Expression::new(format!(
+            "{filter_repos} == ''",
+            filter_repos = filter_repos_input.expr(),
+        )))
         .runs_on(runners::LINUX_SMALL)
         .timeout_minutes(1u32)
         .add_step(authenticate)

tooling/xtask/src/tasks/workflows/extensions/bump_version.rs 🔗

@@ -5,17 +5,18 @@ use gh_workflow::{
 use indoc::indoc;
 
 use crate::tasks::workflows::{
+    GenerateWorkflowArgs, GitSha,
     extensions::WithAppSecrets,
     runners,
     steps::{CommonJobConditions, NamedJob, named},
     vars::{JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token},
 };
 
-pub(crate) fn bump_version() -> Workflow {
+pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow {
     let (determine_bump_type, bump_type) = determine_bump_type();
     let bump_type = bump_type.as_job_output(&determine_bump_type);
 
-    let call_bump_version = call_bump_version(&determine_bump_type, bump_type);
+    let call_bump_version = call_bump_version(args.sha.as_ref(), &determine_bump_type, bump_type);
 
     named::workflow()
         .on(Event::default()
@@ -32,6 +33,7 @@ pub(crate) fn bump_version() -> Workflow {
 }
 
 pub(crate) fn call_bump_version(
+    target_ref: Option<&GitSha>,
     depending_job: &NamedJob,
     bump_type: JobOutput,
 ) -> NamedJob<UsesJob> {
@@ -51,7 +53,7 @@ pub(crate) fn call_bump_version(
             "zed-industries",
             "zed",
             ".github/workflows/extension_bump.yml",
-            "main",
+            target_ref.map_or("main", AsRef::as_ref),
         )
         .add_need(depending_job.name.clone())
         .with(

tooling/xtask/src/tasks/workflows/extensions/run_tests.rs 🔗

@@ -1,12 +1,13 @@
 use gh_workflow::{Event, Job, Level, Permissions, PullRequest, Push, UsesJob, Workflow};
 
 use crate::tasks::workflows::{
+    GenerateWorkflowArgs, GitSha,
     steps::{NamedJob, named},
     vars::one_workflow_per_non_main_branch_and_token,
 };
 
-pub(crate) fn run_tests() -> Workflow {
-    let call_extension_tests = call_extension_tests();
+pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow {
+    let call_extension_tests = call_extension_tests(args.sha.as_ref());
     named::workflow()
         .on(Event::default()
             .pull_request(PullRequest::default().add_branch("**"))
@@ -15,14 +16,14 @@ pub(crate) fn run_tests() -> Workflow {
         .add_job(call_extension_tests.name, call_extension_tests.job)
 }
 
-pub(crate) fn call_extension_tests() -> NamedJob<UsesJob> {
+pub(crate) fn call_extension_tests(target_ref: Option<&GitSha>) -> NamedJob<UsesJob> {
     let job = Job::default()
         .permissions(Permissions::default().contents(Level::Read))
         .uses(
             "zed-industries",
             "zed",
             ".github/workflows/extension_tests.yml",
-            "main",
+            target_ref.map_or("main", AsRef::as_ref),
         );
 
     named::job(job)

tooling/xtask/src/tasks/workflows/steps.rs 🔗

@@ -131,22 +131,12 @@ impl From<CheckoutStep> for Step<Use> {
                 FetchDepth::Full => step.add_with(("fetch-depth", 0)),
                 FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)),
             })
-            .map(|step| match value.token {
-                Some(token) => step.add_with(("token", token)),
-                None => step,
-            })
-            .map(|step| match value.path {
-                Some(path) => step.add_with(("path", path)),
-                None => step,
-            })
-            .map(|step| match value.repository {
-                Some(repository) => step.add_with(("repository", repository)),
-                None => step,
-            })
-            .map(|step| match value.ref_ {
-                Some(ref_) => step.add_with(("ref", ref_)),
-                None => step,
+            .when_some(value.path, |step, path| step.add_with(("path", path)))
+            .when_some(value.repository, |step, repository| {
+                step.add_with(("repository", repository))
             })
+            .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_)))
+            .when_some(value.token, |step, token| step.add_with(("token", token)))
     }
 }