diff --git a/.github/workflows/compliance_check.yml b/.github/workflows/compliance_check.yml
index f09c460c233b04e78df01e7828b4def737dec16e..c8bce3302ada3e56564616f145ac660fdc743d20 100644
--- a/.github/workflows/compliance_check.yml
+++ b/.github/workflows/compliance_check.yml
@@ -6,6 +6,7 @@ env:
on:
schedule:
- cron: 30 17 * * 2
+ workflow_dispatch: {}
jobs:
scheduled_compliance_check:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
@@ -34,22 +35,44 @@ jobs:
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- id: run-compliance-check
name: compliance_check::scheduled_compliance_check::run_compliance_check
- run: cargo xtask compliance "$LATEST_TAG" --branch main --report-path target/compliance-report
+ run: |
+ echo "tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
+ cargo xtask compliance "$LATEST_TAG" --branch main --report-path compliance-report
env:
LATEST_TAG: ${{ steps.determine-version.outputs.tag }}
GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }}
GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- - name: compliance_check::scheduled_compliance_check::send_failure_slack_notification
- if: failure()
+ - name: '@actions/upload-artifact compliance-report.md'
+ if: always()
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+ with:
+ name: compliance-report.md
+ path: target/compliance-report.md
+ if-no-files-found: error
+ - name: send_compliance_slack_notification
+ if: always()
run: |
- MESSAGE="⚠️ Scheduled compliance check failed for upcoming preview release $LATEST_TAG: There are PRs with missing reviews."
+ REPORT_CONTENT=""
+ if [ -f "target/compliance-report.md" ]; then
+ REPORT_CONTENT=$(cat "target/compliance-report.md")
+ fi
+
+ if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
+ STATUS="✅ Scheduled compliance check passed for $COMPLIANCE_TAG"
+ else
+ STATUS="⚠️ Scheduled compliance check failed for $COMPLIANCE_TAG"
+ fi
+
+ MESSAGE=$(printf "%s\n\nReport: %s\nPRs needing review: %s\n\n%s" "$STATUS" "$ARTIFACT_URL" "https://github.com/zed-industries/zed/pulls?q=is%3Apr+is%3Aclosed+label%3A%22PR+state%3Aneeds+review%22" "$REPORT_CONTENT")
curl -X POST -H 'Content-type: application/json' \
--data "$(jq -n --arg text "$MESSAGE" '{"text": $text}')" \
"$SLACK_WEBHOOK"
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_WORKFLOW_FAILURES }}
- LATEST_TAG: ${{ steps.determine-version.outputs.tag }}
+ COMPLIANCE_OUTCOME: ${{ steps.run-compliance-check.outcome }}
+ COMPLIANCE_TAG: ${{ steps.determine-version.outputs.tag }}
+ ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts
defaults:
run:
shell: bash -euxo pipefail {0}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1401144ab3abda17dd4f526edd42166d37a47a49..f20f64c7a54151fedcd3b4e36b4dc492578b827d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -295,9 +295,7 @@ jobs:
timeout-minutes: 60
compliance_check:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
- runs-on: namespace-profile-16x32-ubuntu-2204
- env:
- COMPLIANCE_FILE_PATH: compliance.md
+ runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
@@ -312,25 +310,33 @@ jobs:
path: ~/.rustup
- id: run-compliance-check
name: release::compliance_check::run_compliance_check
- run: cargo xtask compliance "$GITHUB_REF_NAME" --report-path "$COMPLIANCE_FILE_OUTPUT"
+ run: |
+ cargo xtask compliance "$GITHUB_REF_NAME" --report-path compliance-report
env:
GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }}
GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- - name: release::compliance_check::send_compliance_slack_notification
+ - name: '@actions/upload-artifact compliance-report.md'
+ if: always()
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+ with:
+ name: compliance-report.md
+ path: target/compliance-report.md
+ if-no-files-found: error
+ - name: send_compliance_slack_notification
if: always()
run: |
- if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
- STATUS="✅ Compliance check passed for $GITHUB_REF_NAME"
- else
- STATUS="❌ Compliance check failed for $GITHUB_REF_NAME"
+ REPORT_CONTENT=""
+ if [ -f "target/compliance-report.md" ]; then
+ REPORT_CONTENT=$(cat "target/compliance-report.md")
fi
- REPORT_CONTENT=""
- if [ -f "$COMPLIANCE_FILE_OUTPUT" ]; then
- REPORT_CONTENT=$(cat "$REPORT_FILE")
+ if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
+ STATUS="✅ Compliance check passed for $COMPLIANCE_TAG"
+ else
+ STATUS="❌ Compliance check failed for $COMPLIANCE_TAG"
fi
- MESSAGE=$(printf "%s\n\n%s" "$STATUS" "$REPORT_CONTENT")
+ MESSAGE=$(printf "%s\n\nReport: %s\nPRs needing review: %s\n\n%s" "$STATUS" "$ARTIFACT_URL" "https://github.com/zed-industries/zed/pulls?q=is%3Apr+is%3Aclosed+label%3A%22PR+state%3Aneeds+review%22" "$REPORT_CONTENT")
curl -X POST -H 'Content-type: application/json' \
--data "$(jq -n --arg text "$MESSAGE" '{"text": $text}')" \
@@ -338,6 +344,9 @@ jobs:
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_WORKFLOW_FAILURES }}
COMPLIANCE_OUTCOME: ${{ steps.run-compliance-check.outcome }}
+ COMPLIANCE_TAG: ${{ github.ref_name }}
+ ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts
+ timeout-minutes: 60
bundle_linux_aarch64:
needs:
- run_tests_linux
@@ -671,32 +680,42 @@ jobs:
path: ~/.rustup
- id: run-post-upload-compliance-check
name: release::validate_release_assets::run_post_upload_compliance_check
- run: cargo xtask compliance "$GITHUB_REF_NAME" --report-path target/compliance-report
+ run: |
+ cargo xtask compliance "$GITHUB_REF_NAME" --report-path compliance-report
env:
GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }}
GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- - name: release::validate_release_assets::send_post_upload_compliance_notification
+ - name: '@actions/upload-artifact compliance-report.md'
+ if: always()
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+ with:
+ name: compliance-report.md
+ path: target/compliance-report.md
+ if-no-files-found: error
+ - name: send_compliance_slack_notification
if: always()
run: |
- if [ -z "$COMPLIANCE_OUTCOME" ] || [ "$COMPLIANCE_OUTCOME" == "skipped" ]; then
- echo "Compliance check was skipped, not sending notification"
- exit 0
+ REPORT_CONTENT=""
+ if [ -f "target/compliance-report.md" ]; then
+ REPORT_CONTENT=$(cat "target/compliance-report.md")
fi
- TAG="$GITHUB_REF_NAME"
-
if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
- MESSAGE="✅ Post-upload compliance re-check passed for $TAG"
+ STATUS="✅ Compliance check passed for $COMPLIANCE_TAG"
else
- MESSAGE="❌ Post-upload compliance re-check failed for $TAG"
+ STATUS="❌ Compliance check failed for $COMPLIANCE_TAG"
fi
+ MESSAGE=$(printf "%s\n\nReport: %s\nPRs needing review: %s\n\n%s" "$STATUS" "$ARTIFACT_URL" "https://github.com/zed-industries/zed/pulls?q=is%3Apr+is%3Aclosed+label%3A%22PR+state%3Aneeds+review%22" "$REPORT_CONTENT")
+
curl -X POST -H 'Content-type: application/json' \
--data "$(jq -n --arg text "$MESSAGE" '{"text": $text}')" \
"$SLACK_WEBHOOK"
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_WORKFLOW_FAILURES }}
COMPLIANCE_OUTCOME: ${{ steps.run-post-upload-compliance-check.outcome }}
+ COMPLIANCE_TAG: ${{ github.ref_name }}
+ ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts
auto_release_preview:
needs:
- validate_release_assets
diff --git a/Cargo.lock b/Cargo.lock
index 71419daa363fd95e704d7298c5ef9bb743e8e34d..838a55201d97846b9778781d7ff9a55d50440525 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -629,13 +629,17 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
+ "collections",
"futures 0.3.32",
"http_client",
+ "language_model_core",
+ "log",
"schemars",
"serde",
"serde_json",
"strum 0.27.2",
"thiserror 2.0.17",
+ "tiktoken-rs",
]
[[package]]
@@ -1229,10 +1233,13 @@ dependencies = [
name = "auto_update_ui"
version = "0.1.0"
dependencies = [
+ "agent_settings",
"anyhow",
"auto_update",
"client",
+ "db",
"editor",
+ "fs",
"gpui",
"markdown_preview",
"release_channel",
@@ -1240,9 +1247,11 @@ dependencies = [
"serde",
"serde_json",
"smol",
+ "telemetry",
"ui",
"util",
"workspace",
+ "zed_actions",
]
[[package]]
@@ -2903,7 +2912,6 @@ dependencies = [
"http_client",
"http_client_tls",
"httparse",
- "language_model",
"log",
"objc2-foundation",
"parking_lot",
@@ -2959,6 +2967,7 @@ dependencies = [
"http_client",
"parking_lot",
"serde_json",
+ "smol",
"thiserror 2.0.17",
"yawc",
]
@@ -3204,7 +3213,6 @@ dependencies = [
"anyhow",
"call",
"channel",
- "chrono",
"client",
"collections",
"db",
@@ -3213,7 +3221,6 @@ dependencies = [
"fuzzy",
"gpui",
"livekit_client",
- "log",
"menu",
"notifications",
"picker",
@@ -3228,7 +3235,6 @@ dependencies = [
"theme",
"theme_settings",
"time",
- "time_format",
"title_bar",
"ui",
"util",
@@ -5162,6 +5168,7 @@ dependencies = [
"buffer_diff",
"client",
"clock",
+ "cloud_api_client",
"cloud_api_types",
"cloud_llm_client",
"collections",
@@ -5641,7 +5648,7 @@ dependencies = [
name = "env_var"
version = "0.1.0"
dependencies = [
- "gpui",
+ "gpui_shared_string",
]
[[package]]
@@ -6183,6 +6190,7 @@ dependencies = [
"file_icons",
"futures 0.3.32",
"fuzzy",
+ "fuzzy_nucleo",
"gpui",
"menu",
"open_path_prompt",
@@ -6740,6 +6748,15 @@ dependencies = [
"thread_local",
]
+[[package]]
+name = "fuzzy_nucleo"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "nucleo",
+ "util",
+]
+
[[package]]
name = "gaoya"
version = "0.2.0"
@@ -7458,11 +7475,13 @@ dependencies = [
"anyhow",
"futures 0.3.32",
"http_client",
+ "language_model_core",
+ "log",
"schemars",
"serde",
"serde_json",
- "settings",
"strum 0.27.2",
+ "tiktoken-rs",
]
[[package]]
@@ -7531,6 +7550,7 @@ dependencies = [
"getrandom 0.3.4",
"gpui_macros",
"gpui_platform",
+ "gpui_shared_string",
"gpui_util",
"gpui_web",
"http_client",
@@ -7700,6 +7720,16 @@ dependencies = [
"gpui_windows",
]
+[[package]]
+name = "gpui_shared_string"
+version = "0.1.0"
+dependencies = [
+ "derive_more",
+ "gpui_util",
+ "schemars",
+ "serde",
+]
+
[[package]]
name = "gpui_tokio"
version = "0.1.0"
@@ -9348,7 +9378,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"collections",
- "gpui",
+ "gpui_shared_string",
"log",
"lsp",
"parking_lot",
@@ -9387,12 +9417,8 @@ dependencies = [
name = "language_model"
version = "0.1.0"
dependencies = [
- "anthropic",
"anyhow",
"base64 0.22.1",
- "cloud_api_client",
- "cloud_api_types",
- "cloud_llm_client",
"collections",
"credentials_provider",
"env_var",
@@ -9401,16 +9427,31 @@ dependencies = [
"http_client",
"icons",
"image",
+ "language_model_core",
"log",
- "open_ai",
- "open_router",
"parking_lot",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.17",
+ "util",
+]
+
+[[package]]
+name = "language_model_core"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "cloud_llm_client",
+ "futures 0.3.32",
+ "gpui_shared_string",
+ "http_client",
+ "partial-json-fixer",
"schemars",
"serde",
"serde_json",
"smol",
+ "strum 0.27.2",
"thiserror 2.0.17",
- "util",
]
[[package]]
@@ -9426,8 +9467,8 @@ dependencies = [
"base64 0.22.1",
"bedrock",
"client",
+ "cloud_api_client",
"cloud_api_types",
- "cloud_llm_client",
"collections",
"component",
"convert_case 0.8.0",
@@ -9446,6 +9487,7 @@ dependencies = [
"http_client",
"language",
"language_model",
+ "language_models_cloud",
"lmstudio",
"log",
"menu",
@@ -9454,17 +9496,14 @@ dependencies = [
"open_ai",
"open_router",
"opencode",
- "partial-json-fixer",
"pretty_assertions",
"release_channel",
"schemars",
- "semver",
"serde",
"serde_json",
"settings",
"smol",
"strum 0.27.2",
- "thiserror 2.0.17",
"tiktoken-rs",
"tokio",
"ui",
@@ -9474,6 +9513,28 @@ dependencies = [
"x_ai",
]
+[[package]]
+name = "language_models_cloud"
+version = "0.1.0"
+dependencies = [
+ "anthropic",
+ "anyhow",
+ "cloud_llm_client",
+ "futures 0.3.32",
+ "google_ai",
+ "gpui",
+ "http_client",
+ "language_model",
+ "open_ai",
+ "schemars",
+ "semver",
+ "serde",
+ "serde_json",
+ "smol",
+ "thiserror 2.0.17",
+ "x_ai",
+]
+
[[package]]
name = "language_onboarding"
version = "0.1.0"
@@ -11063,6 +11124,27 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "nucleo"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4"
+dependencies = [
+ "nucleo-matcher",
+ "parking_lot",
+ "rayon",
+]
+
+[[package]]
+name = "nucleo-matcher"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
+dependencies = [
+ "memchr",
+ "unicode-segmentation",
+]
+
[[package]]
name = "num"
version = "0.4.3"
@@ -11507,6 +11589,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"client",
+ "cloud_api_types",
+ "collections",
"component",
"db",
"documented",
@@ -11600,16 +11684,19 @@ name = "open_ai"
version = "0.1.0"
dependencies = [
"anyhow",
+ "collections",
"futures 0.3.32",
"http_client",
+ "language_model_core",
"log",
+ "pretty_assertions",
"rand 0.9.2",
"schemars",
"serde",
"serde_json",
- "settings",
"strum 0.27.2",
"thiserror 2.0.17",
+ "tiktoken-rs",
]
[[package]]
@@ -11641,6 +11728,7 @@ dependencies = [
"anyhow",
"futures 0.3.32",
"http_client",
+ "language_model_core",
"schemars",
"serde",
"serde_json",
@@ -12809,7 +12897,6 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
name = "platform_title_bar"
version = "0.1.0"
dependencies = [
- "feature_flags",
"gpui",
"project",
"settings",
@@ -13203,6 +13290,7 @@ dependencies = [
"fs",
"futures 0.3.32",
"fuzzy",
+ "fuzzy_nucleo",
"git",
"git2",
"git_hosting_providers",
@@ -15769,6 +15857,7 @@ dependencies = [
"collections",
"derive_more",
"gpui",
+ "language_model_core",
"log",
"schemars",
"serde",
@@ -15840,7 +15929,6 @@ dependencies = [
"edit_prediction",
"edit_prediction_ui",
"editor",
- "feature_flags",
"fs",
"futures 0.3.32",
"fuzzy",
@@ -15990,7 +16078,6 @@ dependencies = [
"anyhow",
"chrono",
"editor",
- "feature_flags",
"fs",
"futures 0.3.32",
"git",
@@ -17651,12 +17738,15 @@ dependencies = [
name = "theme_selector"
version = "0.1.0"
dependencies = [
+ "editor",
"fs",
"fuzzy",
"gpui",
"log",
"picker",
+ "project",
"serde",
+ "serde_json",
"settings",
"telemetry",
"theme",
@@ -20151,6 +20241,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"client",
+ "cloud_api_client",
"cloud_api_types",
"cloud_llm_client",
"futures 0.3.32",
@@ -21754,9 +21845,11 @@ name = "x_ai"
version = "0.1.0"
dependencies = [
"anyhow",
+ "language_model_core",
"schemars",
"serde",
"strum 0.27.2",
+ "tiktoken-rs",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 5cb5b991b645ec1b78b16f48493c7c8dc1426344..5a7fc9caaf982953168855671bebbcf4f010df03 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,7 @@ members = [
"crates/fs",
"crates/fs_benchmarks",
"crates/fuzzy",
+ "crates/fuzzy_nucleo",
"crates/git",
"crates/git_graph",
"crates/git_hosting_providers",
@@ -86,6 +87,7 @@ members = [
"crates/google_ai",
"crates/grammars",
"crates/gpui",
+ "crates/gpui_shared_string",
"crates/gpui_linux",
"crates/gpui_macos",
"crates/gpui_macros",
@@ -109,7 +111,9 @@ members = [
"crates/language_core",
"crates/language_extension",
"crates/language_model",
+ "crates/language_model_core",
"crates/language_models",
+ "crates/language_models_cloud",
"crates/language_onboarding",
"crates/language_selector",
"crates/language_tools",
@@ -325,6 +329,7 @@ file_finder = { path = "crates/file_finder" }
file_icons = { path = "crates/file_icons" }
fs = { path = "crates/fs" }
fuzzy = { path = "crates/fuzzy" }
+fuzzy_nucleo = { path = "crates/fuzzy_nucleo" }
git = { path = "crates/git" }
git_graph = { path = "crates/git_graph" }
git_hosting_providers = { path = "crates/git_hosting_providers" }
@@ -333,6 +338,7 @@ go_to_line = { path = "crates/go_to_line" }
google_ai = { path = "crates/google_ai" }
grammars = { path = "crates/grammars" }
gpui = { path = "crates/gpui", default-features = false }
+gpui_shared_string = { path = "crates/gpui_shared_string" }
gpui_linux = { path = "crates/gpui_linux", default-features = false }
gpui_macos = { path = "crates/gpui_macos", default-features = false }
gpui_macros = { path = "crates/gpui_macros" }
@@ -359,7 +365,9 @@ language = { path = "crates/language" }
language_core = { path = "crates/language_core" }
language_extension = { path = "crates/language_extension" }
language_model = { path = "crates/language_model" }
+language_model_core = { path = "crates/language_model_core" }
language_models = { path = "crates/language_models" }
+language_models_cloud = { path = "crates/language_models_cloud" }
language_onboarding = { path = "crates/language_onboarding" }
language_selector = { path = "crates/language_selector" }
language_tools = { path = "crates/language_tools" }
@@ -609,6 +617,7 @@ naga = { version = "29.0", features = ["wgsl-in"] }
nanoid = "0.4"
nbformat = "1.2.0"
nix = "0.29"
+nucleo = "0.5"
num-format = "0.4.4"
objc = "0.2"
objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] }
diff --git a/assets/icons/folder_open_add.svg b/assets/icons/folder_open_add.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d5ebbdaa8b080037a2faee0ee0fc3606eec9c6ca
--- /dev/null
+++ b/assets/icons/folder_open_add.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/folder_plus.svg b/assets/icons/folder_plus.svg
deleted file mode 100644
index a543448ed6197043291369bee640e23b6ad729b9..0000000000000000000000000000000000000000
--- a/assets/icons/folder_plus.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/assets/icons/open_new_window.svg b/assets/icons/open_new_window.svg
new file mode 100644
index 0000000000000000000000000000000000000000..c81d49f9ff9edfbc965055568efc72e0214efb41
--- /dev/null
+++ b/assets/icons/open_new_window.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/images/ai_grid.svg b/assets/images/ai_grid.svg
deleted file mode 100644
index 49e8c4139efb985277f812f4a1cd6656a713ba6a..0000000000000000000000000000000000000000
--- a/assets/images/ai_grid.svg
+++ /dev/null
@@ -1,334 +0,0 @@
-
diff --git a/assets/images/business_stamp.svg b/assets/images/business_stamp.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a7bbf6914b3e4f1486bfa652afa3b85b75a7e6bd
--- /dev/null
+++ b/assets/images/business_stamp.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/pro_trial_stamp.svg b/assets/images/pro_trial_stamp.svg
index a3f9095120876949c51f1cd03f8fb8499bf4ea3e..101ff361b311f72446b200f1ef838d73a8261ee9 100644
--- a/assets/images/pro_trial_stamp.svg
+++ b/assets/images/pro_trial_stamp.svg
@@ -1 +1 @@
-
+
diff --git a/assets/images/pro_user_stamp.svg b/assets/images/pro_user_stamp.svg
index d037a9e8335d31f4b515a674f3bfa9495bf8a6a3..7b560d0b2534fd45aac709922f12eb889326ad6c 100644
--- a/assets/images/pro_user_stamp.svg
+++ b/assets/images/pro_user_stamp.svg
@@ -1 +1 @@
-
+
diff --git a/assets/images/student_stamp.svg b/assets/images/student_stamp.svg
new file mode 100644
index 0000000000000000000000000000000000000000..947f4bb78557d23a3604776895a10953a8a56ae7
--- /dev/null
+++ b/assets/images/student_stamp.svg
@@ -0,0 +1 @@
+
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 5ecca68e0404b400af2c285dc51df0a65d6fe07a..190f915d86f1c3f5a69449fbc17ca456800f6d52 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -1189,6 +1189,7 @@
"ctrl-e": ["terminal::SendKeystroke", "ctrl-e"],
"ctrl-o": ["terminal::SendKeystroke", "ctrl-o"],
"ctrl-w": ["terminal::SendKeystroke", "ctrl-w"],
+ "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"],
"ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"],
"ctrl-shift-a": "editor::SelectAll",
"find": "buffer_search::Deploy",
@@ -1435,7 +1436,7 @@
{
"context": "NotebookEditor",
"bindings": {
- "shift-enter": "notebook::Run",
+ "shift-enter": "notebook::RunAndAdvance",
"ctrl-enter": "notebook::Run",
"ctrl-shift-enter": "notebook::RunAll",
"alt-up": "notebook::MoveCellUp",
@@ -1446,11 +1447,19 @@
"ctrl-c": "notebook::InterruptKernel",
},
},
+ {
+ "context": "NotebookEditor && notebook_mode == command",
+ "bindings": {
+ "enter": "notebook::EnterEditMode",
+ "down": "menu::SelectNext",
+ "up": "menu::SelectPrevious",
+ },
+ },
{
"context": "NotebookEditor > Editor",
"bindings": {
"enter": "editor::Newline",
- "shift-enter": "notebook::Run",
+ "shift-enter": "notebook::RunAndAdvance",
"ctrl-enter": "notebook::Run",
"ctrl-shift-enter": "notebook::RunAll",
"alt-up": "notebook::MoveCellUp",
@@ -1459,6 +1468,7 @@
"ctrl-shift-m": "notebook::AddMarkdownBlock",
"ctrl-shift-r": "notebook::RestartKernel",
"ctrl-c": "notebook::InterruptKernel",
+ "escape": "notebook::EnterCommandMode",
},
},
{
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index c74b5900001a2c798076783b2741aba84ffc4b15..e1119be12980e49538bfbfe81d0834b61d0181f3 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -1269,6 +1269,7 @@
"escape": ["terminal::SendKeystroke", "escape"],
"enter": ["terminal::SendKeystroke", "enter"],
"ctrl-c": ["terminal::SendKeystroke", "ctrl-c"],
+ "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"],
"ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"],
"shift-pageup": "terminal::ScrollPageUp",
"cmd-up": "terminal::ScrollPageUp",
@@ -1571,7 +1572,7 @@
{
"context": "NotebookEditor",
"bindings": {
- "shift-enter": "notebook::Run",
+ "shift-enter": "notebook::RunAndAdvance",
"cmd-enter": "notebook::Run",
"cmd-shift-enter": "notebook::RunAll",
"alt-up": "notebook::MoveCellUp",
@@ -1582,11 +1583,19 @@
"cmd-c": "notebook::InterruptKernel",
},
},
+ {
+ "context": "NotebookEditor && notebook_mode == command",
+ "bindings": {
+ "enter": "notebook::EnterEditMode",
+ "down": "menu::SelectNext",
+ "up": "menu::SelectPrevious",
+ },
+ },
{
"context": "NotebookEditor > Editor",
"bindings": {
"enter": "editor::Newline",
- "shift-enter": "notebook::Run",
+ "shift-enter": "notebook::RunAndAdvance",
"cmd-enter": "notebook::Run",
"cmd-shift-enter": "notebook::RunAll",
"alt-up": "notebook::MoveCellUp",
@@ -1595,6 +1604,7 @@
"cmd-shift-m": "notebook::AddMarkdownBlock",
"cmd-shift-r": "notebook::RestartKernel",
"cmd-c": "notebook::InterruptKernel",
+ "escape": "notebook::EnterCommandMode",
},
},
]
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index a9eb3933423ff60fe60ac391b12773ce7146fb0d..0736e49162d8186990c2af62f971245e1fc825fe 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -1488,7 +1488,7 @@
{
"context": "NotebookEditor",
"bindings": {
- "shift-enter": "notebook::Run",
+ "shift-enter": "notebook::RunAndAdvance",
"ctrl-enter": "notebook::Run",
"ctrl-shift-enter": "notebook::RunAll",
"alt-up": "notebook::MoveCellUp",
@@ -1499,11 +1499,19 @@
"ctrl-c": "notebook::InterruptKernel",
},
},
+ {
+ "context": "NotebookEditor && notebook_mode == command",
+ "bindings": {
+ "enter": "notebook::EnterEditMode",
+ "down": "menu::SelectNext",
+ "up": "menu::SelectPrevious",
+ },
+ },
{
"context": "NotebookEditor > Editor",
"bindings": {
"enter": "editor::Newline",
- "shift-enter": "notebook::Run",
+ "shift-enter": "notebook::RunAndAdvance",
"ctrl-enter": "notebook::Run",
"ctrl-shift-enter": "notebook::RunAll",
"alt-up": "notebook::MoveCellUp",
@@ -1512,6 +1520,7 @@
"ctrl-shift-m": "notebook::AddMarkdownBlock",
"ctrl-shift-r": "notebook::RestartKernel",
"ctrl-c": "notebook::InterruptKernel",
+ "escape": "notebook::EnterCommandMode",
},
},
]
diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json
index 220b44ff537ffa791b23c0c5b7d86b6768d74dc2..efc375795ee70c57c372aa8c56352bcae5f8e8f7 100644
--- a/assets/keymaps/vim.json
+++ b/assets/keymaps/vim.json
@@ -1110,10 +1110,24 @@
},
{
"context": "NotebookEditor > Editor && VimControl && vim_mode == normal",
-
"bindings": {
"j": "notebook::NotebookMoveDown",
"k": "notebook::NotebookMoveUp",
+ "escape": "notebook::EnterCommandMode",
+ },
+ },
+ {
+ "context": "NotebookEditor && notebook_mode == command",
+ "bindings": {
+ "j": "menu::SelectNext",
+ "k": "menu::SelectPrevious",
+ "g g": "menu::SelectFirst",
+ "shift-g": "menu::SelectLast",
+ "i": "notebook::EnterEditMode",
+ "a": "notebook::EnterEditMode",
+ "enter": "notebook::EnterEditMode",
+ "shift-enter": "notebook::RunAndAdvance",
+ "ctrl-enter": "notebook::Run",
},
},
{
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 5e1eb0e68d2f8a17f89422597aa29b99516333e8..8d6f067c9af4e4e02ce1b613911d0dbc59077526 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -717,7 +717,7 @@
// Default width of the project panel.
"default_width": 240,
// Where to dock the project panel. Can be 'left' or 'right'.
- "dock": "left",
+ "dock": "right",
// Spacing between worktree entries in the project panel. Can be 'comfortable' or 'standard'.
"entry_spacing": "comfortable",
// Whether to show file icons in the project panel.
@@ -819,7 +819,7 @@
// Default width of the outline panel.
"default_width": 300,
// Where to dock the outline panel. Can be 'left' or 'right'.
- "dock": "left",
+ "dock": "right",
// Whether to show file icons in the outline panel.
"file_icons": true,
// Whether to show folder icons or chevrons for directories in the outline panel.
@@ -871,7 +871,7 @@
// Whether to show the collaboration panel button in the status bar.
"button": true,
// Where to dock the collaboration panel. Can be 'left' or 'right'.
- "dock": "left",
+ "dock": "right",
// Default width of the collaboration panel.
"default_width": 240,
},
@@ -879,7 +879,7 @@
// Whether to show the git panel button in the status bar.
"button": true,
// Where to dock the git panel. Can be 'left' or 'right'.
- "dock": "left",
+ "dock": "right",
// Default width of the git panel.
"default_width": 360,
// Style of the git status indicator in the panel.
@@ -936,16 +936,6 @@
// For example: typing `:wave:` gets replaced with `👋`.
"auto_replace_emoji_shortcode": true,
},
- "notification_panel": {
- // Whether to show the notification panel button in the status bar.
- "button": true,
- // Where to dock the notification panel. Can be 'left' or 'right'.
- "dock": "right",
- // Default width of the notification panel.
- "default_width": 380,
- // Whether to show a badge on the notification panel icon with the count of unread notifications.
- "show_count_badge": false,
- },
"agent": {
// Whether the inline assistant should use streaming tools, when available
"inline_assistant_use_streaming_tools": true,
@@ -954,7 +944,7 @@
// Whether to show the agent panel button in the status bar.
"button": true,
// Where to dock the agent panel. Can be 'left', 'right' or 'bottom'.
- "dock": "right",
+ "dock": "left",
// Whether the agent panel should use flexible (proportional) sizing.
//
// Default: true
@@ -965,6 +955,9 @@
"default_width": 640,
// Default height when the agent panel is docked to the bottom.
"default_height": 320,
+ // Maximum content width when the agent panel is wider than this value.
+ // Content will be centered within the panel.
+ "max_content_width": 850,
// The default model to use when creating new threads.
"default_model": {
// The provider to use.
@@ -2417,6 +2410,7 @@
"toggle_relative_line_numbers": false,
"use_system_clipboard": "always",
"use_smartcase_find": false,
+ "use_regex_search": true,
"gdefault": false,
"highlight_on_yank_duration": 200,
"custom_digraphs": {},
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index 0bcb8254c8b8123eef3faaa913bb360de8dcc76d..36c9fb40c4a573e09da05618a29c1898cced60ad 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -1032,6 +1032,7 @@ pub struct AcpThread {
connection: Rc,
token_usage: Option,
prompt_capabilities: acp::PromptCapabilities,
+ available_commands: Vec,
_observe_prompt_capabilities: Task>,
terminals: HashMap>,
pending_terminal_output: HashMap>>,
@@ -1220,6 +1221,7 @@ impl AcpThread {
session_id,
token_usage: None,
prompt_capabilities,
+ available_commands: Vec::new(),
_observe_prompt_capabilities: task,
terminals: HashMap::default(),
pending_terminal_output: HashMap::default(),
@@ -1239,6 +1241,10 @@ impl AcpThread {
self.prompt_capabilities.clone()
}
+ pub fn available_commands(&self) -> &[acp::AvailableCommand] {
+ &self.available_commands
+ }
+
pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
self.draft_prompt.as_deref()
}
@@ -1419,7 +1425,10 @@ impl AcpThread {
acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate {
available_commands,
..
- }) => cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)),
+ }) => {
+ self.available_commands = available_commands.clone();
+ cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands));
+ }
acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate {
current_mode_id,
..
diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs
index 58e779da59aef176464839ed6f2d6a5c16e4bc12..ff9e735b6c4181588ed5cddbd6dada7fbae5f18f 100644
--- a/crates/agent/src/tool_permissions.rs
+++ b/crates/agent/src/tool_permissions.rs
@@ -574,6 +574,7 @@ mod tests {
flexible: true,
default_width: px(300.),
default_height: px(600.),
+ max_content_width: px(850.),
default_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs
index 0086a82f4e79c9924502202873ceb2b25d2e66fb..9b013f111e7eaa981652d8868dfcf3c098d9dc7e 100644
--- a/crates/agent/src/tools/read_file_tool.rs
+++ b/crates/agent/src/tools/read_file_tool.rs
@@ -5,7 +5,7 @@ use futures::FutureExt as _;
use gpui::{App, Entity, SharedString, Task};
use indoc::formatdoc;
use language::Point;
-use language_model::{LanguageModelImage, LanguageModelToolResultContent};
+use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent};
use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs
index 5f452bc9c0e2e9c2322042583295894a5866b053..e56db9df927ab3cdf838587f1cb4f9514eb5a758 100644
--- a/crates/agent_servers/src/acp.rs
+++ b/crates/agent_servers/src/acp.rs
@@ -325,7 +325,7 @@ impl AcpConnection {
// Use the one the agent provides if we have one
.map(|info| info.name.into())
// Otherwise, just use the name
- .unwrap_or_else(|| agent_id.0.to_string().into());
+ .unwrap_or_else(|| agent_id.0.clone());
let session_list = if response
.agent_capabilities
diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs
index 0c68d2f25d54f966d1cc0a93476457bbba79c959..f04095662513a31ad5e8b204509669942ec4793f 100644
--- a/crates/agent_settings/src/agent_settings.rs
+++ b/crates/agent_settings/src/agent_settings.rs
@@ -31,7 +31,6 @@ pub struct PanelLayout {
pub(crate) outline_panel_dock: Option,
pub(crate) collaboration_panel_dock: Option,
pub(crate) git_panel_dock: Option,
- pub(crate) notification_panel_button: Option,
}
impl PanelLayout {
@@ -41,7 +40,6 @@ impl PanelLayout {
outline_panel_dock: Some(DockSide::Right),
collaboration_panel_dock: Some(DockPosition::Right),
git_panel_dock: Some(DockPosition::Right),
- notification_panel_button: Some(false),
};
const EDITOR: Self = Self {
@@ -50,7 +48,6 @@ impl PanelLayout {
outline_panel_dock: Some(DockSide::Left),
collaboration_panel_dock: Some(DockPosition::Left),
git_panel_dock: Some(DockPosition::Left),
- notification_panel_button: Some(true),
};
pub fn is_agent_layout(&self) -> bool {
@@ -68,7 +65,6 @@ impl PanelLayout {
outline_panel_dock: content.outline_panel.as_ref().and_then(|p| p.dock),
collaboration_panel_dock: content.collaboration_panel.as_ref().and_then(|p| p.dock),
git_panel_dock: content.git_panel.as_ref().and_then(|p| p.dock),
- notification_panel_button: content.notification_panel.as_ref().and_then(|p| p.button),
}
}
@@ -78,7 +74,6 @@ impl PanelLayout {
settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
settings.collaboration_panel.get_or_insert_default().dock = self.collaboration_panel_dock;
settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
- settings.notification_panel.get_or_insert_default().button = self.notification_panel_button;
}
fn write_diff_to(&self, current_merged: &PanelLayout, settings: &mut SettingsContent) {
@@ -98,10 +93,6 @@ impl PanelLayout {
if self.git_panel_dock != current_merged.git_panel_dock {
settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
}
- if self.notification_panel_button != current_merged.notification_panel_button {
- settings.notification_panel.get_or_insert_default().button =
- self.notification_panel_button;
- }
}
fn backfill_to(&self, user_layout: &PanelLayout, settings: &mut SettingsContent) {
@@ -121,10 +112,6 @@ impl PanelLayout {
if user_layout.git_panel_dock.is_none() {
settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
}
- if user_layout.notification_panel_button.is_none() {
- settings.notification_panel.get_or_insert_default().button =
- self.notification_panel_button;
- }
}
}
@@ -154,6 +141,7 @@ pub struct AgentSettings {
pub sidebar_side: SidebarDockPosition,
pub default_width: Pixels,
pub default_height: Pixels,
+ pub max_content_width: Pixels,
pub default_model: Option,
pub inline_assistant_model: Option,
pub inline_assistant_use_streaming_tools: bool,
@@ -600,6 +588,7 @@ impl Settings for AgentSettings {
sidebar_side: agent.sidebar_side.unwrap(),
default_width: px(agent.default_width.unwrap()),
default_height: px(agent.default_height.unwrap()),
+ max_content_width: px(agent.max_content_width.unwrap()),
flexible: agent.flexible.unwrap(),
default_model: Some(agent.default_model.unwrap()),
inline_assistant_model: agent.inline_assistant_model,
@@ -739,14 +728,6 @@ mod tests {
use settings::ToolPermissionMode;
use settings::ToolPermissionsContent;
- fn set_agent_v2_defaults(cx: &mut gpui::App) {
- SettingsStore::update_global(cx, |store, cx| {
- store.update_default_settings(cx, |defaults| {
- PanelLayout::AGENT.write_to(defaults);
- });
- });
- }
-
#[test]
fn test_compiled_regex_case_insensitive() {
let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
@@ -1227,9 +1208,6 @@ mod tests {
project::DisableAiSettings::register(cx);
AgentSettings::register(cx);
- // Test defaults are editor layout; switch to agent V2.
- set_agent_v2_defaults(cx);
-
// Should be Agent with an empty user layout (user hasn't customized).
let layout = AgentSettings::get_layout(cx);
let WindowLayout::Agent(Some(user_layout)) = layout else {
@@ -1255,7 +1233,6 @@ mod tests {
assert_eq!(user_layout.outline_panel_dock, None);
assert_eq!(user_layout.collaboration_panel_dock, None);
assert_eq!(user_layout.git_panel_dock, None);
- assert_eq!(user_layout.notification_panel_button, None);
// User sets a combination that doesn't match either preset:
// agent on the left but project panel also on the left.
@@ -1363,9 +1340,6 @@ mod tests {
project::DisableAiSettings::register(cx);
AgentSettings::register(cx);
- // Apply the agent V2 defaults.
- set_agent_v2_defaults(cx);
-
// User has agent=left (matches preset) and project_panel=left (does not)
SettingsStore::update_global(cx, |store, cx| {
store
@@ -1454,7 +1428,7 @@ mod tests {
cx.run_until_parked();
- // Read back the file and apply it, then switch to agent V2 defaults.
+ // Read back the file and apply it.
let written = fs.load(paths::settings_file().as_path()).await.unwrap();
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
@@ -1478,10 +1452,6 @@ mod tests {
Some(DockPosition::Left)
);
assert_eq!(user_layout.git_panel_dock, Some(DockPosition::Left));
- assert_eq!(user_layout.notification_panel_button, Some(true));
-
- // Now switch defaults to agent V2.
- set_agent_v2_defaults(cx);
// Even though defaults are now agent, the backfilled user settings
// keep everything in the editor layout. The user's experience
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 41900e71e5d3ad7e5327ee7e04f73cb05eed5a5b..f522f05fdeb93ea0a2063f03f6da8827133e8648 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -19,7 +19,6 @@ use project::AgentId;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelProviderSetting, LanguageModelSelection};
-use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
use zed_actions::agent::{
AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent,
ResolveConflictsWithAgent, ReviewBranchDiff,
@@ -28,25 +27,24 @@ use zed_actions::agent::{
use crate::thread_metadata_store::ThreadMetadataStore;
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
- Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown,
- OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
- ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
+ Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
+ OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
+ StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
+ thread_branch_picker::ThreadBranchPicker,
+ thread_worktree_picker::ThreadWorktreePicker,
ui::EndTrialUpsell,
};
use crate::{
Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
NewNativeAgentThreadFromSummary,
};
-use crate::{
- DEFAULT_THREAD_TITLE,
- ui::{AcpOnboardingModal, HoldForDefault},
-};
+use crate::{DEFAULT_THREAD_TITLE, ui::AcpOnboardingModal};
use crate::{ExpandMessageEditor, ThreadHistoryView};
use crate::{ManageProfiles, ThreadHistoryViewEvent};
use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
-use agent_settings::AgentSettings;
+use agent_settings::{AgentSettings, WindowLayout};
use ai_onboarding::AgentPanelOnboarding;
use anyhow::{Context as _, Result, anyhow};
use client::UserStore;
@@ -73,8 +71,8 @@ use terminal::terminal_settings::TerminalSettings;
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use theme_settings::ThemeSettings;
use ui::{
- Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide,
- PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
+ Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
+ PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
};
use util::{ResultExt as _, debug_panic};
use workspace::{
@@ -281,7 +279,7 @@ pub fn init(cx: &mut App) {
if let Some(panel) = workspace.panel::(cx) {
panel.update(cx, |panel, _| {
panel
- .on_boarding_upsell_dismissed
+ .new_user_onboarding_upsell_dismissed
.store(false, Ordering::Release);
});
}
@@ -616,13 +614,130 @@ enum WhichFontSize {
None,
}
+struct StartThreadInLabel {
+ prefix: Option,
+ label: SharedString,
+ suffix: Option,
+}
+
impl StartThreadIn {
- fn label(&self) -> SharedString {
+ fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel {
+ match self {
+ Self::LocalProject => {
+ let suffix = project.active_repository(cx).and_then(|repo| {
+ let repo = repo.read(cx);
+ let work_dir = &repo.original_repo_abs_path;
+ let visible_paths: Vec<_> = project
+ .visible_worktrees(cx)
+ .map(|wt| wt.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ for linked in repo.linked_worktrees() {
+ if visible_paths.contains(&linked.path) {
+ return Some(SharedString::from(format!(
+ "({})",
+ linked.display_name()
+ )));
+ }
+ }
+
+ if visible_paths
+ .iter()
+ .any(|p| p.as_path() == work_dir.as_ref())
+ {
+ return Some("(main)".into());
+ }
+
+ None
+ });
+
+ StartThreadInLabel {
+ prefix: None,
+ label: "Current Worktree".into(),
+ suffix,
+ }
+ }
+ Self::NewWorktree {
+ worktree_name: Some(worktree_name),
+ ..
+ } => StartThreadInLabel {
+ prefix: Some("New:".into()),
+ label: worktree_name.clone().into(),
+ suffix: None,
+ },
+ Self::NewWorktree { .. } => StartThreadInLabel {
+ prefix: None,
+ label: "New Git Worktree".into(),
+ suffix: None,
+ },
+ Self::LinkedWorktree { display_name, .. } => StartThreadInLabel {
+ prefix: Some("From:".into()),
+ label: display_name.clone().into(),
+ suffix: None,
+ },
+ }
+ }
+
+ fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option {
match self {
- Self::LocalProject => "Current Worktree".into(),
- Self::NewWorktree => "New Git Worktree".into(),
+ Self::NewWorktree { branch_target, .. } => {
+ let (branch_name, is_occupied) = match branch_target {
+ NewWorktreeBranchTarget::CurrentBranch => {
+ let name: SharedString = if project.repositories(cx).len() > 1 {
+ "current branches".into()
+ } else {
+ project
+ .active_repository(cx)
+ .and_then(|repo| {
+ repo.read(cx)
+ .branch
+ .as_ref()
+ .map(|branch| SharedString::from(branch.name().to_string()))
+ })
+ .unwrap_or_else(|| "HEAD".into())
+ };
+ (name, false)
+ }
+ NewWorktreeBranchTarget::ExistingBranch { name } => {
+ let occupied = Self::is_branch_occupied(name, project, cx);
+ (name.clone().into(), occupied)
+ }
+ NewWorktreeBranchTarget::CreateBranch {
+ from_ref: Some(from_ref),
+ ..
+ } => {
+ let occupied = Self::is_branch_occupied(from_ref, project, cx);
+ (from_ref.clone().into(), occupied)
+ }
+ NewWorktreeBranchTarget::CreateBranch { name, .. } => {
+ (name.clone().into(), false)
+ }
+ };
+
+ let prefix = if is_occupied {
+ Some("New From:".into())
+ } else {
+ None
+ };
+
+ Some(StartThreadInLabel {
+ prefix,
+ label: branch_name,
+ suffix: None,
+ })
+ }
+ _ => None,
}
}
+
+ fn is_branch_occupied(branch_name: &str, project: &Project, cx: &App) -> bool {
+ project.repositories(cx).values().any(|repo| {
+ repo.read(cx)
+ .linked_worktrees
+ .iter()
+ .any(|wt| wt.branch_name() == Some(branch_name))
+ })
+ }
}
#[derive(Clone, Debug)]
@@ -632,6 +747,17 @@ pub enum WorktreeCreationStatus {
Error(SharedString),
}
+#[derive(Clone, Debug)]
+enum WorktreeCreationArgs {
+ New {
+ worktree_name: Option,
+ branch_target: NewWorktreeBranchTarget,
+ },
+ Linked {
+ worktree_path: PathBuf,
+ },
+}
+
impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
@@ -662,7 +788,8 @@ pub struct AgentPanel {
previous_view: Option,
background_threads: HashMap>,
new_thread_menu_handle: PopoverMenuHandle,
- start_thread_in_menu_handle: PopoverMenuHandle,
+ start_thread_in_menu_handle: PopoverMenuHandle,
+ thread_branch_menu_handle: PopoverMenuHandle,
agent_panel_menu_handle: PopoverMenuHandle,
agent_navigation_menu_handle: PopoverMenuHandle,
agent_navigation_menu: Option>,
@@ -670,7 +797,10 @@ pub struct AgentPanel {
_project_subscription: Subscription,
zoomed: bool,
pending_serialization: Option>>,
- onboarding: Entity,
+ new_user_onboarding: Entity,
+ new_user_onboarding_upsell_dismissed: AtomicBool,
+ agent_layout_onboarding: Entity,
+ agent_layout_onboarding_dismissed: AtomicBool,
selected_agent: Agent,
start_thread_in: StartThreadIn,
worktree_creation_status: Option,
@@ -678,7 +808,6 @@ pub struct AgentPanel {
_active_thread_focus_subscription: Option,
_worktree_creation_task: Option>,
show_trust_workspace_message: bool,
- on_boarding_upsell_dismissed: AtomicBool,
_active_view_observation: Option,
}
@@ -689,7 +818,7 @@ impl AgentPanel {
};
let selected_agent = self.selected_agent.clone();
- let start_thread_in = Some(self.start_thread_in);
+ let start_thread_in = Some(self.start_thread_in.clone());
let last_active_thread = self.active_agent_thread(cx).map(|thread| {
let thread = thread.read(cx);
@@ -794,18 +923,19 @@ impl AgentPanel {
} else if let Some(agent) = global_fallback {
panel.selected_agent = agent;
}
- if let Some(start_thread_in) = serialized_panel.start_thread_in {
- let is_worktree_flag_enabled =
- cx.has_flag::();
+ if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
let is_valid = match &start_thread_in {
StartThreadIn::LocalProject => true,
- StartThreadIn::NewWorktree => {
+ StartThreadIn::NewWorktree { .. } => {
let project = panel.project.read(cx);
- is_worktree_flag_enabled && !project.is_via_collab()
+ !project.is_via_collab()
+ }
+ StartThreadIn::LinkedWorktree { path, .. } => {
+ path.exists()
}
};
if is_valid {
- panel.start_thread_in = start_thread_in;
+ panel.start_thread_in = start_thread_in.clone();
} else {
log::info!(
"deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
@@ -911,18 +1041,55 @@ impl AgentPanel {
client,
move |_window, cx| {
weak_panel
- .update(cx, |panel, _| {
- panel
- .on_boarding_upsell_dismissed
- .store(true, Ordering::Release);
+ .update(cx, |panel, cx| {
+ panel.dismiss_ai_onboarding(cx);
})
.ok();
- OnboardingUpsell::set_dismissed(true, cx);
},
cx,
)
});
+ let weak_panel = cx.entity().downgrade();
+
+ let layout = AgentSettings::get_layout(cx);
+ let is_agent_layout = matches!(layout, WindowLayout::Agent(_));
+
+ let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding {
+ use_agent_layout: Arc::new({
+ let fs = fs.clone();
+ let weak_panel = weak_panel.clone();
+ move |_window, cx| {
+ AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
+ weak_panel
+ .update(cx, |panel, cx| {
+ panel.dismiss_agent_layout_onboarding(cx);
+ })
+ .ok();
+ }
+ }),
+ revert_to_editor_layout: Arc::new({
+ let fs = fs.clone();
+ let weak_panel = weak_panel.clone();
+ move |_window, cx| {
+ AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
+ weak_panel
+ .update(cx, |panel, cx| {
+ panel.dismiss_agent_layout_onboarding(cx);
+ })
+ .ok();
+ }
+ }),
+ dismissed: Arc::new(move |_window, cx| {
+ weak_panel
+ .update(cx, |panel, cx| {
+ panel.dismiss_agent_layout_onboarding(cx);
+ })
+ .ok();
+ }),
+ is_agent_layout,
+ });
+
// Subscribe to extension events to sync agent servers when extensions change
let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
{
@@ -979,6 +1146,7 @@ impl AgentPanel {
background_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
+ thread_branch_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu: None,
@@ -986,7 +1154,8 @@ impl AgentPanel {
_project_subscription,
zoomed: false,
pending_serialization: None,
- onboarding,
+ new_user_onboarding: onboarding,
+ agent_layout_onboarding,
thread_store,
selected_agent: Agent::default(),
start_thread_in: StartThreadIn::default(),
@@ -995,7 +1164,10 @@ impl AgentPanel {
_active_thread_focus_subscription: None,
_worktree_creation_task: None,
show_trust_workspace_message: false,
- on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
+ new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
+ agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed(
+ cx,
+ )),
_active_view_observation: None,
};
@@ -1948,24 +2120,37 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context,
) {
- if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::() {
- return;
- }
-
- let new_target = match *action {
+ let new_target = match action {
StartThreadIn::LocalProject => StartThreadIn::LocalProject,
- StartThreadIn::NewWorktree => {
+ StartThreadIn::NewWorktree { .. } => {
+ if !self.project_has_git_repository(cx) {
+ log::error!(
+ "set_start_thread_in: cannot use worktree mode without a git repository"
+ );
+ return;
+ }
+ if self.project.read(cx).is_via_collab() {
+ log::error!(
+ "set_start_thread_in: cannot use worktree mode in a collab project"
+ );
+ return;
+ }
+ action.clone()
+ }
+ StartThreadIn::LinkedWorktree { .. } => {
if !self.project_has_git_repository(cx) {
log::error!(
- "set_start_thread_in: cannot use NewWorktree without a git repository"
+ "set_start_thread_in: cannot use LinkedWorktree without a git repository"
);
return;
}
if self.project.read(cx).is_via_collab() {
- log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
+ log::error!(
+ "set_start_thread_in: cannot use LinkedWorktree in a collab project"
+ );
return;
}
- StartThreadIn::NewWorktree
+ action.clone()
}
};
self.start_thread_in = new_target;
@@ -1977,9 +2162,14 @@ impl AgentPanel {
}
fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context) {
- let next = match self.start_thread_in {
- StartThreadIn::LocalProject => StartThreadIn::NewWorktree,
- StartThreadIn::NewWorktree => StartThreadIn::LocalProject,
+ let next = match &self.start_thread_in {
+ StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
+ StartThreadIn::LocalProject
+ }
};
self.set_start_thread_in(&next, window, cx);
}
@@ -1991,7 +2181,10 @@ impl AgentPanel {
NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
NewThreadLocation::NewWorktree => {
if self.project_has_git_repository(cx) {
- StartThreadIn::NewWorktree
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ }
} else {
StartThreadIn::LocalProject
}
@@ -2219,15 +2412,39 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context,
) {
- if self.start_thread_in == StartThreadIn::NewWorktree {
- self.handle_worktree_creation_requested(content, window, cx);
- } else {
- cx.defer_in(window, move |_this, window, cx| {
- thread_view.update(cx, |thread_view, cx| {
- let editor = thread_view.message_editor.clone();
- thread_view.send_impl(editor, window, cx);
+ match &self.start_thread_in {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target,
+ } => {
+ self.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::New {
+ worktree_name: worktree_name.clone(),
+ branch_target: branch_target.clone(),
+ },
+ window,
+ cx,
+ );
+ }
+ StartThreadIn::LinkedWorktree { path, .. } => {
+ self.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::Linked {
+ worktree_path: path.clone(),
+ },
+ window,
+ cx,
+ );
+ }
+ StartThreadIn::LocalProject => {
+ cx.defer_in(window, move |_this, window, cx| {
+ thread_view.update(cx, |thread_view, cx| {
+ let editor = thread_view.message_editor.clone();
+ thread_view.send_impl(editor, window, cx);
+ });
});
- });
+ }
}
}
@@ -2289,6 +2506,33 @@ impl AgentPanel {
(git_repos, non_git_paths)
}
+ fn resolve_worktree_branch_target(
+ branch_target: &NewWorktreeBranchTarget,
+ existing_branches: &HashSet,
+ occupied_branches: &HashSet,
+ ) -> Result<(String, bool, Option)> {
+ let generate_branch_name = || -> Result {
+ let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
+ let mut rng = rand::rng();
+ crate::branch_names::generate_branch_name(&refs, &mut rng)
+ .ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
+ };
+
+ match branch_target {
+ NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
+ Ok((name.clone(), false, from_ref.clone()))
+ }
+ NewWorktreeBranchTarget::ExistingBranch { name } => {
+ if occupied_branches.contains(name) {
+ Ok((generate_branch_name()?, false, Some(name.clone())))
+ } else {
+ Ok((name.clone(), true, None))
+ }
+ }
+ NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
+ }
+ }
+
/// Kicks off an async git-worktree creation for each repository. Returns:
///
/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
@@ -2297,7 +2541,10 @@ impl AgentPanel {
/// later to remap open editor tabs into the new workspace.
fn start_worktree_creations(
git_repos: &[Entity],
+ worktree_name: Option,
branch_name: &str,
+ use_existing_branch: bool,
+ start_point: Option,
worktree_directory_setting: &str,
cx: &mut Context,
) -> Result<(
@@ -2311,12 +2558,27 @@ impl AgentPanel {
let mut creation_infos = Vec::new();
let mut path_remapping = Vec::new();
+ let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
+
for repo in git_repos {
let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
let new_path =
- repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
- let receiver =
- repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
+ repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
+ let target = if use_existing_branch {
+ debug_assert!(
+ git_repos.len() == 1,
+ "use_existing_branch should only be true for a single repo"
+ );
+ git::repository::CreateWorktreeTarget::ExistingBranch {
+ branch_name: branch_name.to_string(),
+ }
+ } else {
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: branch_name.to_string(),
+ base_sha: start_point.clone(),
+ }
+ };
+ let receiver = repo.create_worktree(target, new_path.clone());
let work_dir = repo.work_directory_abs_path.clone();
anyhow::Ok((work_dir, new_path, receiver))
})?;
@@ -2419,9 +2681,10 @@ impl AgentPanel {
cx.notify();
}
- fn handle_worktree_creation_requested(
+ fn handle_worktree_requested(
&mut self,
content: Vec,
+ args: WorktreeCreationArgs,
window: &mut Window,
cx: &mut Context,
) {
@@ -2437,7 +2700,7 @@ impl AgentPanel {
let (git_repos, non_git_paths) = self.classify_worktrees(cx);
- if git_repos.is_empty() {
+ if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
self.set_worktree_creation_error(
"No git repositories found in the project".into(),
window,
@@ -2446,17 +2709,31 @@ impl AgentPanel {
return;
}
- // Kick off branch listing as early as possible so it can run
- // concurrently with the remaining synchronous setup work.
- let branch_receivers: Vec<_> = git_repos
- .iter()
- .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
- .collect();
-
- let worktree_directory_setting = ProjectSettings::get_global(cx)
- .git
- .worktree_directory
- .clone();
+ let (branch_receivers, worktree_receivers, worktree_directory_setting) =
+ if matches!(args, WorktreeCreationArgs::New { .. }) {
+ (
+ Some(
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
+ .collect::>(),
+ ),
+ Some(
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
+ .collect::>(),
+ ),
+ Some(
+ ProjectSettings::get_global(cx)
+ .git
+ .worktree_directory
+ .clone(),
+ ),
+ )
+ } else {
+ (None, None, None)
+ };
let active_file_path = self.workspace.upgrade().and_then(|workspace| {
let workspace = workspace.read(cx);
@@ -2476,77 +2753,124 @@ impl AgentPanel {
let selected_agent = self.selected_agent();
let task = cx.spawn_in(window, async move |this, cx| {
- // Await the branch listings we kicked off earlier.
- let mut existing_branches = Vec::new();
- for result in futures::future::join_all(branch_receivers).await {
- match result {
- Ok(Ok(branches)) => {
- for branch in branches {
- existing_branches.push(branch.name().to_string());
+ let (all_paths, path_remapping, has_non_git) = match args {
+ WorktreeCreationArgs::New {
+ worktree_name,
+ branch_target,
+ } => {
+ let branch_receivers = branch_receivers
+ .expect("branch receivers must be prepared for new worktree creation");
+ let worktree_receivers = worktree_receivers
+ .expect("worktree receivers must be prepared for new worktree creation");
+ let worktree_directory_setting = worktree_directory_setting
+ .expect("worktree directory must be prepared for new worktree creation");
+
+ let mut existing_branches = HashSet::default();
+ for result in futures::future::join_all(branch_receivers).await {
+ match result {
+ Ok(Ok(branches)) => {
+ for branch in branches {
+ existing_branches.insert(branch.name().to_string());
+ }
+ }
+ Ok(Err(err)) => {
+ Err::<(), _>(err).log_err();
+ }
+ Err(_) => {}
}
}
- Ok(Err(err)) => {
- Err::<(), _>(err).log_err();
+
+ let mut occupied_branches = HashSet::default();
+ for result in futures::future::join_all(worktree_receivers).await {
+ match result {
+ Ok(Ok(worktrees)) => {
+ for worktree in worktrees {
+ if let Some(branch_name) = worktree.branch_name() {
+ occupied_branches.insert(branch_name.to_string());
+ }
+ }
+ }
+ Ok(Err(err)) => {
+ Err::<(), _>(err).log_err();
+ }
+ Err(_) => {}
+ }
}
- Err(_) => {}
- }
- }
- let existing_branch_refs: Vec<&str> =
- existing_branches.iter().map(|s| s.as_str()).collect();
- let mut rng = rand::rng();
- let branch_name =
- match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) {
- Some(name) => name,
- None => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- "Failed to generate a unique branch name".into(),
- window,
+ let (branch_name, use_existing_branch, start_point) =
+ match Self::resolve_worktree_branch_target(
+ &branch_target,
+ &existing_branches,
+ &occupied_branches,
+ ) {
+ Ok(target) => target,
+ Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ err.to_string().into(),
+ window,
+ cx,
+ );
+ })?;
+ return anyhow::Ok(());
+ }
+ };
+
+ let (creation_infos, path_remapping) =
+ match this.update_in(cx, |_this, _window, cx| {
+ Self::start_worktree_creations(
+ &git_repos,
+ worktree_name,
+ &branch_name,
+ use_existing_branch,
+ start_point,
+ &worktree_directory_setting,
cx,
- );
- })?;
- return anyhow::Ok(());
- }
- };
+ )
+ }) {
+ Ok(Ok(result)) => result,
+ Ok(Err(err)) | Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ format!("Failed to validate worktree directory: {err}")
+ .into(),
+ window,
+ cx,
+ );
+ })
+ .log_err();
+ return anyhow::Ok(());
+ }
+ };
- let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
- Self::start_worktree_creations(
- &git_repos,
- &branch_name,
- &worktree_directory_setting,
- cx,
- )
- }) {
- Ok(Ok(result)) => result,
- Ok(Err(err)) | Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- format!("Failed to validate worktree directory: {err}").into(),
- window,
- cx,
- );
- })
- .log_err();
- return anyhow::Ok(());
- }
- };
+ let created_paths =
+ match Self::await_and_rollback_on_failure(creation_infos, cx).await {
+ Ok(paths) => paths,
+ Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ format!("{err}").into(),
+ window,
+ cx,
+ );
+ })?;
+ return anyhow::Ok(());
+ }
+ };
- let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
- {
- Ok(paths) => paths,
- Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(format!("{err}").into(), window, cx);
- })?;
- return anyhow::Ok(());
+ let mut all_paths = created_paths;
+ let has_non_git = !non_git_paths.is_empty();
+ all_paths.extend(non_git_paths.iter().cloned());
+ (all_paths, path_remapping, has_non_git)
+ }
+ WorktreeCreationArgs::Linked { worktree_path } => {
+ let mut all_paths = vec![worktree_path];
+ let has_non_git = !non_git_paths.is_empty();
+ all_paths.extend(non_git_paths.iter().cloned());
+ (all_paths, Vec::new(), has_non_git)
}
};
- let mut all_paths = created_paths;
- let has_non_git = !non_git_paths.is_empty();
- all_paths.extend(non_git_paths.iter().cloned());
-
let app_state = match workspace.upgrade() {
Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
None => {
@@ -2562,7 +2886,7 @@ impl AgentPanel {
};
let this_for_error = this.clone();
- if let Err(err) = Self::setup_new_workspace(
+ if let Err(err) = Self::open_worktree_workspace_and_start_thread(
this,
all_paths,
app_state,
@@ -2595,7 +2919,7 @@ impl AgentPanel {
}));
}
- async fn setup_new_workspace(
+ async fn open_worktree_workspace_and_start_thread(
this: WeakEntity,
all_paths: Vec,
app_state: Arc,
@@ -2989,17 +3313,11 @@ impl AgentPanel {
fn render_panel_options_menu(
&self,
- window: &mut Window,
+ _window: &mut Window,
cx: &mut Context,
) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
- let full_screen_label = if self.is_zoomed(window, cx) {
- "Disable Full Screen"
- } else {
- "Enable Full Screen"
- };
-
let conversation_view = match &self.active_view {
ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
_ => None,
@@ -3075,8 +3393,7 @@ impl AgentPanel {
.action("Profiles", Box::new(ManageProfiles::default()))
.action("Settings", Box::new(OpenSettings))
.separator()
- .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar))
- .action(full_screen_label, Box::new(ToggleZoom));
+ .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
if has_auth_methods {
menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
@@ -3088,47 +3405,6 @@ impl AgentPanel {
})
}
- fn render_recent_entries_menu(
- &self,
- icon: IconName,
- corner: Corner,
- cx: &mut Context,
- ) -> impl IntoElement {
- let focus_handle = self.focus_handle(cx);
-
- PopoverMenu::new("agent-nav-menu")
- .trigger_with_tooltip(
- IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
- {
- move |_window, cx| {
- Tooltip::for_action_in(
- "Toggle Recently Updated Threads",
- &ToggleNavigationMenu,
- &focus_handle,
- cx,
- )
- }
- },
- )
- .anchor(corner)
- .with_handle(self.agent_navigation_menu_handle.clone())
- .menu({
- let menu = self.agent_navigation_menu.clone();
- move |window, cx| {
- telemetry::event!("View Thread History Clicked");
-
- if let Some(menu) = menu.as_ref() {
- menu.update(cx, |_, cx| {
- cx.defer_in(window, |menu, window, cx| {
- menu.rebuild(window, cx);
- });
- })
- }
- menu.clone()
- }
- })
- }
-
fn render_toolbar_back_button(&self, cx: &mut Context) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
@@ -3149,24 +3425,16 @@ impl AgentPanel {
}
fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement {
- use settings::{NewThreadLocation, Settings};
-
let focus_handle = self.focus_handle(cx);
- let has_git_repo = self.project_has_git_repository(cx);
- let is_via_collab = self.project.read(cx).is_via_collab();
- let fs = self.fs.clone();
let is_creating = matches!(
self.worktree_creation_status,
Some(WorktreeCreationStatus::Creating)
);
- let current_target = self.start_thread_in;
- let trigger_label = self.start_thread_in.label();
-
- let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
- let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
- let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
+ let trigger_parts = self
+ .start_thread_in
+ .trigger_label(self.project.read(cx), cx);
let icon = if self.start_thread_in_menu_handle.is_deployed() {
IconName::ChevronUp
@@ -3174,17 +3442,20 @@ impl AgentPanel {
IconName::ChevronDown
};
- let trigger_button = Button::new("thread-target-trigger", trigger_label)
- .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
- .disabled(is_creating);
+ let trigger_button = ButtonLike::new("thread-target-trigger")
+ .disabled(is_creating)
+ .when_some(trigger_parts.prefix, |this, prefix| {
+ this.child(Label::new(prefix).color(Color::Muted))
+ })
+ .child(Label::new(trigger_parts.label))
+ .when_some(trigger_parts.suffix, |this, suffix| {
+ this.child(Label::new(suffix).color(Color::Muted))
+ })
+ .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
- let dock_position = AgentSettings::get_global(cx).dock;
- let documentation_side = match dock_position {
- settings::DockPosition::Left => DocumentationSide::Right,
- settings::DockPosition::Bottom | settings::DockPosition::Right => {
- DocumentationSide::Left
- }
- };
+ let project = self.project.clone();
+ let current_target = self.start_thread_in.clone();
+ let fs = self.fs.clone();
PopoverMenu::new("thread-target-selector")
.trigger_with_tooltip(trigger_button, {
@@ -3198,89 +3469,60 @@ impl AgentPanel {
}
})
.menu(move |window, cx| {
- let is_local_selected = current_target == StartThreadIn::LocalProject;
- let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
let fs = fs.clone();
+ Some(cx.new(|cx| {
+ ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx)
+ }))
+ })
+ .with_handle(self.start_thread_in_menu_handle.clone())
+ .anchor(Corner::TopLeft)
+ .offset(gpui::Point {
+ x: px(1.0),
+ y: px(1.0),
+ })
+ }
+
+ fn render_new_worktree_branch_selector(&self, cx: &mut Context) -> impl IntoElement {
+ let is_creating = matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ );
- Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
- let new_worktree_disabled = !has_git_repo || is_via_collab;
+ let project_ref = self.project.read(cx);
+ let trigger_parts = self
+ .start_thread_in
+ .branch_trigger_label(project_ref, cx)
+ .unwrap_or_else(|| StartThreadInLabel {
+ prefix: Some("From:".into()),
+ label: "HEAD".into(),
+ suffix: None,
+ });
- menu.header("Start Thread In…")
- .item(
- ContextMenuEntry::new("Current Worktree")
- .toggleable(IconPosition::End, is_local_selected)
- .documentation_aside(documentation_side, move |_| {
- HoldForDefault::new(is_local_default)
- .more_content(false)
- .into_any_element()
- })
- .handler({
- let fs = fs.clone();
- move |window, cx| {
- if window.modifiers().secondary() {
- update_settings_file(fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(
- NewThreadLocation::LocalProject,
- );
- });
- }
- window.dispatch_action(
- Box::new(StartThreadIn::LocalProject),
- cx,
- );
- }
- }),
- )
- .item({
- let entry = ContextMenuEntry::new("New Git Worktree")
- .toggleable(IconPosition::End, is_new_worktree_selected)
- .disabled(new_worktree_disabled)
- .handler({
- let fs = fs.clone();
- move |window, cx| {
- if window.modifiers().secondary() {
- update_settings_file(fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(
- NewThreadLocation::NewWorktree,
- );
- });
- }
- window.dispatch_action(
- Box::new(StartThreadIn::NewWorktree),
- cx,
- );
- }
- });
-
- if new_worktree_disabled {
- entry.documentation_aside(documentation_side, move |_| {
- let reason = if !has_git_repo {
- "No git repository found in this project."
- } else {
- "Not available for remote/collab projects yet."
- };
- Label::new(reason)
- .color(Color::Muted)
- .size(LabelSize::Small)
- .into_any_element()
- })
- } else {
- entry.documentation_aside(documentation_side, move |_| {
- HoldForDefault::new(is_new_worktree_default)
- .more_content(false)
- .into_any_element()
- })
- }
- })
+ let icon = if self.thread_branch_menu_handle.is_deployed() {
+ IconName::ChevronUp
+ } else {
+ IconName::ChevronDown
+ };
+
+ let trigger_button = ButtonLike::new("thread-branch-trigger")
+ .disabled(is_creating)
+ .when_some(trigger_parts.prefix, |this, prefix| {
+ this.child(Label::new(prefix).color(Color::Muted))
+ })
+ .child(Label::new(trigger_parts.label))
+ .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
+
+ let project = self.project.clone();
+ let current_target = self.start_thread_in.clone();
+
+ PopoverMenu::new("thread-branch-selector")
+ .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
+ .menu(move |window, cx| {
+ Some(cx.new(|cx| {
+ ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx)
}))
})
- .with_handle(self.start_thread_in_menu_handle.clone())
+ .with_handle(self.thread_branch_menu_handle.clone())
.anchor(Corner::TopLeft)
.offset(gpui::Point {
x: px(1.0),
@@ -3539,8 +3781,6 @@ impl AgentPanel {
selected_agent.into_any_element()
};
- let show_history_menu = self.has_history_for_selected_agent(cx);
- let has_v2_flag = cx.has_flag::();
let is_empty_state = !self.active_thread_has_messages(cx);
let is_in_history_or_config = matches!(
@@ -3549,21 +3789,37 @@ impl AgentPanel {
);
let is_full_screen = self.is_zoomed(window, cx);
+ let full_screen_button = if is_full_screen {
+ IconButton::new("disable-full-screen", IconName::Minimize)
+ .icon_size(IconSize::Small)
+ .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.toggle_zoom(&ToggleZoom, window, cx);
+ }))
+ } else {
+ IconButton::new("enable-full-screen", IconName::Maximize)
+ .icon_size(IconSize::Small)
+ .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.toggle_zoom(&ToggleZoom, window, cx);
+ }))
+ };
- let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config;
+ let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
+
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
let base_container = h_flex()
- .id("agent-panel-toolbar")
- .h(Tab::container_height(cx))
- .max_w_full()
+ .size_full()
+ // TODO: This is only until we remove Agent settings from the panel.
+ .when(!is_in_history_or_config, |this| {
+ this.max_w(max_content_width).mx_auto()
+ })
.flex_none()
.justify_between()
- .gap_2()
- .bg(cx.theme().colors().tab_bar_background)
- .border_b_1()
- .border_color(cx.theme().colors().border);
+ .gap_2();
- if use_v2_empty_toolbar {
+ let toolbar_content = if use_v2_empty_toolbar {
let (chevron_icon, icon_color, label_color) =
if self.new_thread_menu_handle.is_deployed() {
(IconName::ChevronUp, Color::Accent, Color::Accent)
@@ -3621,6 +3877,10 @@ impl AgentPanel {
.when(
has_visible_worktrees && self.project_has_git_repository(cx),
|this| this.child(self.render_start_thread_in_selector(cx)),
+ )
+ .when(
+ matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
+ |this| this.child(self.render_new_worktree_branch_selector(cx)),
),
)
.child(
@@ -3630,27 +3890,7 @@ impl AgentPanel {
.gap_1()
.pl_1()
.pr_1()
- .when(show_history_menu && !has_v2_flag, |this| {
- this.child(self.render_recent_entries_menu(
- IconName::MenuAltTemp,
- Corner::TopRight,
- cx,
- ))
- })
- .when(is_full_screen, |this| {
- this.child(
- IconButton::new("disable-full-screen", IconName::Minimize)
- .icon_size(IconSize::Small)
- .tooltip(move |_, cx| {
- Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
- })
- .on_click({
- cx.listener(move |_, _, window, cx| {
- window.dispatch_action(ToggleZoom.boxed_clone(), cx);
- })
- }),
- )
- })
+ .child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
@@ -3696,31 +3936,21 @@ impl AgentPanel {
.pl_1()
.pr_1()
.child(new_thread_menu)
- .when(show_history_menu && !has_v2_flag, |this| {
- this.child(self.render_recent_entries_menu(
- IconName::MenuAltTemp,
- Corner::TopRight,
- cx,
- ))
- })
- .when(is_full_screen, |this| {
- this.child(
- IconButton::new("disable-full-screen", IconName::Minimize)
- .icon_size(IconSize::Small)
- .tooltip(move |_, cx| {
- Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
- })
- .on_click({
- cx.listener(move |_, _, window, cx| {
- window.dispatch_action(ToggleZoom.boxed_clone(), cx);
- })
- }),
- )
- })
+ .child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
- }
+ };
+
+ h_flex()
+ .id("agent-panel-toolbar")
+ .h(Tab::container_height(cx))
+ .flex_shrink_0()
+ .max_w_full()
+ .bg(cx.theme().colors().tab_bar_background)
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(toolbar_content)
}
fn render_worktree_creation_status(&self, cx: &mut Context) -> Option {
@@ -3786,8 +4016,66 @@ impl AgentPanel {
plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
}
- fn should_render_onboarding(&self, cx: &mut Context) -> bool {
- if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) {
+ fn should_render_agent_layout_onboarding(&self, cx: &mut Context) -> bool {
+ // We only want to show this for existing users: those who
+ // have used the agent panel before the sidebar was introduced.
+ // We can infer that state by users having seen the onboarding
+ // at one point, but not the agent layout onboarding.
+
+ let has_messages = self.active_thread_has_messages(cx);
+ let is_dismissed = self
+ .agent_layout_onboarding_dismissed
+ .load(Ordering::Acquire);
+
+ if is_dismissed || has_messages {
+ return false;
+ }
+
+ match &self.active_view {
+ ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => {
+ false
+ }
+ ActiveView::AgentThread { .. } => {
+ let existing_user = self
+ .new_user_onboarding_upsell_dismissed
+ .load(Ordering::Acquire);
+ existing_user
+ }
+ }
+ }
+
+ fn render_agent_layout_onboarding(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ if !self.should_render_agent_layout_onboarding(cx) {
+ return None;
+ }
+
+ Some(div().child(self.agent_layout_onboarding.clone()))
+ }
+
+ fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context) {
+ self.agent_layout_onboarding_dismissed
+ .store(true, Ordering::Release);
+ AgentLayoutOnboarding::set_dismissed(true, cx);
+ cx.notify();
+ }
+
+ fn dismiss_ai_onboarding(&mut self, cx: &mut Context) {
+ self.new_user_onboarding_upsell_dismissed
+ .store(true, Ordering::Release);
+ OnboardingUpsell::set_dismissed(true, cx);
+ self.dismiss_agent_layout_onboarding(cx);
+ cx.notify();
+ }
+
+ fn should_render_new_user_onboarding(&mut self, cx: &mut Context) -> bool {
+ if self
+ .new_user_onboarding_upsell_dismissed
+ .load(Ordering::Acquire)
+ {
return false;
}
@@ -3799,9 +4087,12 @@ impl AgentPanel {
.and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
.is_some_and(|date| date < chrono::Utc::now())
{
- OnboardingUpsell::set_dismissed(true, cx);
- self.on_boarding_upsell_dismissed
- .store(true, Ordering::Release);
+ if !self
+ .new_user_onboarding_upsell_dismissed
+ .load(Ordering::Acquire)
+ {
+ self.dismiss_ai_onboarding(cx);
+ }
return false;
}
@@ -3830,16 +4121,20 @@ impl AgentPanel {
}
}
- fn render_onboarding(
- &self,
+ fn render_new_user_onboarding(
+ &mut self,
_window: &mut Window,
cx: &mut Context,
) -> Option {
- if !self.should_render_onboarding(cx) {
+ if !self.should_render_new_user_onboarding(cx) {
return None;
}
- Some(div().child(self.onboarding.clone()))
+ Some(
+ div()
+ .bg(cx.theme().colors().editor_background)
+ .child(self.new_user_onboarding.clone()),
+ )
}
fn render_trial_end_upsell(
@@ -4033,7 +4328,8 @@ impl Render for AgentPanel {
}))
.child(self.render_toolbar(window, cx))
.children(self.render_workspace_trust_message(cx))
- .children(self.render_onboarding(window, cx))
+ .children(self.render_new_user_onboarding(window, cx))
+ .children(self.render_agent_layout_onboarding(window, cx))
.map(|parent| match &self.active_view {
ActiveView::Uninitialized => parent,
ActiveView::AgentThread {
@@ -4124,6 +4420,12 @@ impl Dismissable for OnboardingUpsell {
const KEY: &'static str = "dismissed-trial-upsell";
}
+struct AgentLayoutOnboarding;
+
+impl Dismissable for AgentLayoutOnboarding {
+ const KEY: &'static str = "dismissed-agent-layout-onboarding";
+}
+
struct TrialEndUpsell;
impl Dismissable for TrialEndUpsell {
@@ -4234,7 +4536,6 @@ mod tests {
};
use acp_thread::{StubAgentConnection, ThreadStatus};
use agent_servers::CODEX_ID;
- use feature_flags::FeatureFlagAppExt;
use fs::FakeFs;
use gpui::{TestAppContext, VisualTestContext};
use project::Project;
@@ -4247,7 +4548,6 @@ mod tests {
async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
@@ -4368,7 +4668,6 @@ mod tests {
async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
@@ -4748,7 +5047,6 @@ mod tests {
async fn setup_panel(cx: &mut TestAppContext) -> (Entity, VisualTestContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
@@ -5104,7 +5402,6 @@ mod tests {
async fn test_thread_target_local_project(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
@@ -5212,7 +5509,6 @@ mod tests {
async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
@@ -5265,13 +5561,23 @@ mod tests {
// Change thread target to NewWorktree.
panel.update_in(cx, |panel, window, cx| {
- panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
+ panel.set_start_thread_in(
+ &StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
- StartThreadIn::NewWorktree,
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
"thread target should be NewWorktree after set_thread_target"
);
});
@@ -5289,7 +5595,10 @@ mod tests {
loaded_panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
- StartThreadIn::NewWorktree,
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
"thread target should survive serialization round-trip"
);
});
@@ -5301,7 +5610,6 @@ mod tests {
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
::set_global(fs.clone(), cx);
@@ -5420,12 +5728,58 @@ mod tests {
);
}
+ #[test]
+ fn test_resolve_worktree_branch_target() {
+ let existing_branches = HashSet::from_iter([
+ "main".to_string(),
+ "feature".to_string(),
+ "origin/main".to_string(),
+ ]);
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::CreateBranch {
+ name: "new-branch".to_string(),
+ from_ref: Some("main".to_string()),
+ },
+ &existing_branches,
+ &HashSet::from_iter(["main".to_string()]),
+ )
+ .unwrap();
+ assert_eq!(
+ resolved,
+ ("new-branch".to_string(), false, Some("main".to_string()))
+ );
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::ExistingBranch {
+ name: "feature".to_string(),
+ },
+ &existing_branches,
+ &HashSet::default(),
+ )
+ .unwrap();
+ assert_eq!(resolved, ("feature".to_string(), true, None));
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::ExistingBranch {
+ name: "main".to_string(),
+ },
+ &existing_branches,
+ &HashSet::from_iter(["main".to_string()]),
+ )
+ .unwrap();
+ assert_eq!(resolved.1, false);
+ assert_eq!(resolved.2, Some("main".to_string()));
+ assert_ne!(resolved.0, "main");
+ assert!(existing_branches.contains("main"));
+ assert!(!existing_branches.contains(&resolved.0));
+ }
+
#[gpui::test]
async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
@@ -5513,7 +5867,14 @@ mod tests {
panel.selected_agent = Agent::Custom {
id: CODEX_ID.into(),
};
- panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
+ panel.set_start_thread_in(
+ &StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
// Verify the panel has the Codex agent selected.
@@ -5532,7 +5893,15 @@ mod tests {
"Hello from test",
))];
panel.update_in(cx, |panel, window, cx| {
- panel.handle_worktree_creation_requested(content, window, cx);
+ panel.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::New {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
// Let the async worktree creation + workspace setup complete.
@@ -5577,7 +5946,6 @@ mod tests {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
@@ -5769,7 +6137,6 @@ mod tests {
async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
// Use an isolated DB so parallel tests can't overwrite our global key.
@@ -5823,7 +6190,6 @@ mod tests {
async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
@@ -5916,7 +6282,6 @@ mod tests {
async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs
index 78b4e3a5a3965c72b96d4ec201139b1d8e510fb2..e19afdecc390268cefbd7be4e5d0759aa2a29c19 100644
--- a/crates/agent_ui/src/agent_registry_ui.rs
+++ b/crates/agent_ui/src/agent_registry_ui.rs
@@ -382,7 +382,7 @@ impl AgentRegistryPage {
self.install_button(agent, install_status, supports_current_platform, cx);
let repository_button = agent.repository().map(|repository| {
- let repository_for_tooltip: SharedString = repository.to_string().into();
+ let repository_for_tooltip = repository.clone();
let repository_for_click = repository.to_string();
IconButton::new(
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 3cae1c6bc5b22214a9f441cc4996c2f96a610be6..2cf4218719a0412534d9832c3cb54587f4c45a73 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -28,14 +28,17 @@ mod terminal_codegen;
mod terminal_inline_assistant;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
+mod thread_branch_picker;
mod thread_history;
mod thread_history_view;
mod thread_import;
pub mod thread_metadata_store;
pub mod thread_worktree_archive;
+mod thread_worktree_picker;
pub mod threads_archive_view;
mod ui;
+use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
@@ -43,9 +46,9 @@ use ::ui::IconName;
use agent_client_protocol as acp;
use agent_settings::{AgentProfileId, AgentSettings};
use command_palette_hooks::CommandPaletteFilter;
-use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
+use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
-use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal as _, Window, actions};
+use gpui::{Action, App, Context, Entity, SharedString, Window, actions};
use language::{
LanguageRegistry,
language_settings::{AllLanguageSettings, EditPredictionProvider},
@@ -57,7 +60,7 @@ use project::{AgentId, DisableAiSettings};
use prompt_store::PromptBuilder;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use settings::{DockPosition, DockSide, LanguageModelSelection, Settings as _, SettingsStore};
+use settings::{LanguageModelSelection, Settings as _, SettingsStore};
use std::any::TypeId;
use workspace::Workspace;
@@ -78,7 +81,6 @@ use zed_actions;
pub const DEFAULT_THREAD_TITLE: &str = "New Thread";
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
-
actions!(
agent,
[
@@ -315,16 +317,42 @@ impl Agent {
}
}
+/// Describes which branch to use when creating a new git worktree.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case", tag = "kind")]
+pub enum NewWorktreeBranchTarget {
+ /// Create a new randomly named branch from the current HEAD.
+ /// Will match worktree name if the newly created worktree was also randomly named.
+ #[default]
+ CurrentBranch,
+ /// Check out an existing branch, or create a new branch from it if it's
+ /// already occupied by another worktree.
+ ExistingBranch { name: String },
+ /// Create a new branch with an explicit name, optionally from a specific ref.
+ CreateBranch {
+ name: String,
+ #[serde(default)]
+ from_ref: Option,
+ },
+}
+
/// Sets where new threads will run.
-#[derive(
- Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action,
-)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum StartThreadIn {
#[default]
LocalProject,
- NewWorktree,
+ NewWorktree {
+ /// When this is None, Zed will randomly generate a worktree name
+ /// otherwise, the provided name will be used.
+ #[serde(default)]
+ worktree_name: Option,
+ #[serde(default)]
+ branch_target: NewWorktreeBranchTarget,
+ },
+ /// A linked worktree that already exists on disk.
+ LinkedWorktree { path: PathBuf, display_name: String },
}
/// Content to initialize new external agent with.
@@ -483,45 +511,10 @@ pub fn init(
})
.detach();
- // TODO: remove this field when we're ready remove the feature flag
- maybe_backfill_editor_layout(fs, is_new_install, false, cx);
-
- cx.observe_flag::(|is_enabled, cx| {
- SettingsStore::update_global(cx, |store, cx| {
- store.update_default_settings(cx, |defaults| {
- if is_enabled {
- defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left);
- defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right);
- defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right);
- defaults.collaboration_panel.get_or_insert_default().dock =
- Some(DockPosition::Right);
- defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right);
- defaults.notification_panel.get_or_insert_default().button = Some(false);
- } else {
- defaults.agent.get_or_insert_default().dock = Some(DockPosition::Right);
- defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Left);
- defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Left);
- defaults.collaboration_panel.get_or_insert_default().dock =
- Some(DockPosition::Left);
- defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Left);
- defaults.notification_panel.get_or_insert_default().button = Some(true);
- }
- });
- });
- })
- .detach();
+ maybe_backfill_editor_layout(fs, is_new_install, cx);
}
-fn maybe_backfill_editor_layout(
- fs: Arc,
- is_new_install: bool,
- should_run: bool,
- cx: &mut App,
-) {
- if !should_run {
- return;
- }
-
+fn maybe_backfill_editor_layout(fs: Arc, is_new_install: bool, cx: &mut App) {
let kvp = db::kvp::KeyValueStore::global(cx);
let already_backfilled =
util::ResultExt::log_err(kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY))
@@ -546,7 +539,7 @@ fn maybe_backfill_editor_layout(
fn update_command_palette_filter(cx: &mut App) {
let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
let agent_enabled = AgentSettings::get_global(cx).enabled;
- let agent_v2_enabled = cx.has_flag::();
+
let edit_prediction_provider = AllLanguageSettings::get_global(cx)
.edit_predictions
.provider;
@@ -615,11 +608,7 @@ fn update_command_palette_filter(cx: &mut App) {
filter.show_action_types(&[TypeId::of::()]);
}
- if agent_v2_enabled {
- filter.show_namespace("multi_workspace");
- } else {
- filter.hide_namespace("multi_workspace");
- }
+ filter.show_namespace("multi_workspace");
});
}
@@ -688,7 +677,6 @@ mod tests {
use command_palette_hooks::CommandPaletteFilter;
use db::kvp::KeyValueStore;
use editor::actions::AcceptEditPrediction;
- use feature_flags::FeatureFlagAppExt;
use gpui::{BorrowAppContext, TestAppContext, px};
use project::DisableAiSettings;
use settings::{
@@ -714,6 +702,7 @@ mod tests {
flexible: true,
default_width: px(300.),
default_height: px(600.),
+ max_content_width: px(850.),
default_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
@@ -856,7 +845,7 @@ mod tests {
.is_none()
);
- maybe_backfill_editor_layout(fs.clone(), false, true, cx);
+ maybe_backfill_editor_layout(fs.clone(), false, cx);
});
cx.run_until_parked();
@@ -875,7 +864,7 @@ mod tests {
let fs = setup_backfill_test(cx).await;
cx.update(|cx| {
- maybe_backfill_editor_layout(fs.clone(), true, true, cx);
+ maybe_backfill_editor_layout(fs.clone(), true, cx);
});
cx.run_until_parked();
@@ -897,7 +886,7 @@ mod tests {
let fs = setup_backfill_test(cx).await;
cx.update(|cx| {
- maybe_backfill_editor_layout(fs.clone(), false, true, cx);
+ maybe_backfill_editor_layout(fs.clone(), false, cx);
});
cx.run_until_parked();
@@ -905,7 +894,7 @@ mod tests {
let after_first = fs.load(paths::settings_file().as_path()).await.unwrap();
cx.update(|cx| {
- maybe_backfill_editor_layout(fs.clone(), false, true, cx);
+ maybe_backfill_editor_layout(fs.clone(), false, cx);
});
cx.run_until_parked();
diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs
index 44c0baa232222c0ba7c1d54acdecaabacfa85f12..cf2809b87b94eae8a3eb75844539ddffc652b7df 100644
--- a/crates/agent_ui/src/config_options.rs
+++ b/crates/agent_ui/src/config_options.rs
@@ -3,7 +3,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::AgentSessionConfigOptions;
use agent_client_protocol as acp;
use agent_servers::AgentServer;
-use agent_settings::AgentSettings;
+
use collections::HashSet;
use fs::Fs;
use fuzzy::StringMatchCandidate;
@@ -13,14 +13,13 @@ use gpui::{
use ordered_float::OrderedFloat;
use picker::popover_menu::PickerPopoverMenu;
use picker::{Picker, PickerDelegate};
-use settings::{Settings, SettingsStore};
+use settings::SettingsStore;
use ui::{
- DocumentationSide, ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle,
- Tooltip, prelude::*,
+ ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
};
use util::ResultExt as _;
-use crate::ui::HoldForDefault;
+use crate::ui::{HoldForDefault, documentation_aside_side};
const PICKER_THRESHOLD: usize = 5;
@@ -695,13 +694,7 @@ impl PickerDelegate for ConfigOptionPickerDelegate {
let description = description.clone();
let is_default = *is_default;
- let settings = AgentSettings::get_global(cx);
- let side = match settings.dock {
- settings::DockPosition::Left => DocumentationSide::Right,
- settings::DockPosition::Bottom | settings::DockPosition::Right => {
- DocumentationSide::Left
- }
- };
+ let side = documentation_aside_side(cx);
ui::DocumentationAside::new(
side,
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index 149ed2e2fc0f9b22244e0d69deebf5aa7bb7d4c5..de02fdc5d384f08c693df848b63f7ef20bdd28f8 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -22,7 +22,7 @@ use editor::scroll::Autoscroll;
use editor::{
Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
};
-use feature_flags::{AgentSharingFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _};
+use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _};
use file_icons::FileIcons;
use fs::Fs;
use futures::FutureExt as _;
@@ -54,8 +54,8 @@ use theme_settings::AgentFontSize;
use ui::{
Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind,
- KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar,
- prelude::*, right_click_menu,
+ KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
+ right_click_menu,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use util::{debug_panic, defer};
@@ -812,7 +812,7 @@ impl ConversationView {
let agent_id = self.agent.agent_id();
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
thread.read(cx).prompt_capabilities(),
- vec![],
+ thread.read(cx).available_commands().to_vec(),
)));
let action_log = thread.read(cx).action_log().clone();
@@ -1448,40 +1448,24 @@ impl ConversationView {
self.emit_token_limit_telemetry_if_needed(thread, cx);
}
AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
- let mut available_commands = available_commands.clone();
+ if let Some(thread_view) = self.thread_view(&thread_id) {
+ let has_commands = !available_commands.is_empty();
- if thread
- .read(cx)
- .connection()
- .auth_methods()
- .iter()
- .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"));
- }
-
- let has_commands = !available_commands.is_empty();
- if let Some(active) = self.active_thread() {
- active.update(cx, |active, _cx| {
- active
- .session_capabilities
- .write()
- .set_available_commands(available_commands);
- });
- }
-
- let agent_display_name = self
- .agent_server_store
- .read(cx)
- .agent_display_name(&self.agent.agent_id())
- .unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
+ let agent_display_name = self
+ .agent_server_store
+ .read(cx)
+ .agent_display_name(&self.agent.agent_id())
+ .unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
- if let Some(active) = self.active_thread() {
let new_placeholder =
placeholder_text(agent_display_name.as_ref(), has_commands);
- active.update(cx, |active, cx| {
- active.message_editor.update(cx, |editor, cx| {
+
+ thread_view.update(cx, |thread_view, cx| {
+ thread_view
+ .session_capabilities
+ .write()
+ .set_available_commands(available_commands.clone());
+ thread_view.message_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(&new_placeholder, window, cx);
});
});
@@ -2348,9 +2332,9 @@ impl ConversationView {
}
}
+ #[cfg(feature = "audio")]
fn play_notification_sound(&self, window: &Window, cx: &mut App) {
- let settings = AgentSettings::get_global(cx);
- let _visible = window.is_window_active()
+ let visible = window.is_window_active()
&& if let Some(mw) = window.root::().flatten() {
self.agent_panel_visible(&mw, cx)
} else {
@@ -2358,8 +2342,8 @@ impl ConversationView {
.upgrade()
.is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
};
- #[cfg(feature = "audio")]
- if settings.play_sound_when_agent_done.should_play(_visible) {
+ let settings = AgentSettings::get_global(cx);
+ if settings.play_sound_when_agent_done.should_play(visible) {
Audio::play_sound(Sound::AgentDone, cx);
}
}
@@ -2661,7 +2645,6 @@ impl ConversationView {
impl Render for ConversationView {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
self.sync_queued_message_editors(window, cx);
- let v2_flag = cx.has_flag::();
v_flex()
.track_focus(&self.focus_handle)
@@ -2670,17 +2653,18 @@ impl Render for ConversationView {
.child(match &self.server_state {
ServerState::Loading { .. } => v_flex()
.flex_1()
- .when(v2_flag, |this| {
- this.size_full().items_center().justify_center().child(
- Label::new("Loading…").color(Color::Muted).with_animation(
- "loading-agent-label",
- Animation::new(Duration::from_secs(2))
- .repeat()
- .with_easing(pulsating_between(0.3, 0.7)),
- |label, delta| label.alpha(delta),
- ),
- )
- })
+ .size_full()
+ .items_center()
+ .justify_center()
+ .child(
+ Label::new("Loading…").color(Color::Muted).with_animation(
+ "loading-agent-label",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.3, 0.7)),
+ |label, delta| label.alpha(delta),
+ ),
+ )
.into_any(),
ServerState::LoadError { error: e, .. } => v_flex()
.flex_1()
@@ -2989,6 +2973,166 @@ pub(crate) mod tests {
});
}
+ #[derive(Clone)]
+ struct RestoredAvailableCommandsConnection;
+
+ impl AgentConnection for RestoredAvailableCommandsConnection {
+ fn agent_id(&self) -> AgentId {
+ AgentId::new("restored-available-commands")
+ }
+
+ fn telemetry_id(&self) -> SharedString {
+ "restored-available-commands".into()
+ }
+
+ fn new_session(
+ self: Rc,
+ project: Entity,
+ _work_dirs: PathList,
+ cx: &mut App,
+ ) -> Task>> {
+ let thread = build_test_thread(
+ self,
+ project,
+ "RestoredAvailableCommandsConnection",
+ SessionId::new("new-session"),
+ cx,
+ );
+ Task::ready(Ok(thread))
+ }
+
+ fn supports_load_session(&self) -> bool {
+ true
+ }
+
+ fn load_session(
+ self: Rc,
+ session_id: acp::SessionId,
+ project: Entity,
+ _work_dirs: PathList,
+ _title: Option,
+ cx: &mut App,
+ ) -> Task>> {
+ let thread = build_test_thread(
+ self,
+ project,
+ "RestoredAvailableCommandsConnection",
+ session_id,
+ cx,
+ );
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.handle_session_update(
+ acp::SessionUpdate::AvailableCommandsUpdate(
+ acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
+ "help", "Get help",
+ )]),
+ ),
+ cx,
+ )
+ })
+ .expect("available commands update should succeed");
+
+ Task::ready(Ok(thread))
+ }
+
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &[]
+ }
+
+ fn authenticate(
+ &self,
+ _method_id: acp::AuthMethodId,
+ _cx: &mut App,
+ ) -> Task> {
+ Task::ready(Ok(()))
+ }
+
+ fn prompt(
+ &self,
+ _id: Option,
+ _params: acp::PromptRequest,
+ _cx: &mut App,
+ ) -> Task> {
+ Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
+
+ fn into_any(self: Rc) -> Rc {
+ self
+ }
+ }
+
+ #[gpui::test]
+ async fn test_restored_threads_keep_available_commands(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.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+ let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
+
+ let conversation_view = cx.update(|window, cx| {
+ cx.new(|cx| {
+ ConversationView::new(
+ Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
+ connection_store,
+ Agent::Custom { id: "Test".into() },
+ Some(SessionId::new("restored-session")),
+ None,
+ None,
+ None,
+ workspace.downgrade(),
+ project,
+ Some(thread_store),
+ None,
+ window,
+ cx,
+ )
+ })
+ });
+
+ cx.run_until_parked();
+
+ let message_editor = message_editor(&conversation_view, cx);
+ let editor =
+ message_editor.update(cx, |message_editor, _cx| message_editor.editor().clone());
+ let placeholder = editor.update(cx, |editor, cx| editor.placeholder_text(cx));
+
+ active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
+ let available_commands = view
+ .session_capabilities
+ .read()
+ .available_commands()
+ .to_vec();
+ assert_eq!(available_commands.len(), 1);
+ assert_eq!(available_commands[0].name.as_str(), "help");
+ assert_eq!(available_commands[0].description.as_str(), "Get help");
+ });
+
+ assert_eq!(
+ placeholder,
+ Some("Message Test — @ to include context, / for commands".to_string())
+ );
+
+ message_editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("/help", window, cx);
+ });
+
+ let contents_result = message_editor
+ .update(cx, |editor, cx| editor.contents(false, cx))
+ .await;
+
+ assert!(contents_result.is_ok());
+ }
+
#[gpui::test]
async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
init_test(cx);
@@ -3293,7 +3437,6 @@ pub(crate) mod tests {
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
- cx.update_flags(true, vec!["agent-v2".to_string()]);
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
::set_global(fs.clone(), cx);
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 25af09832f3473aa690c7b205e1b56bab86e9709..8f9a8a1f8578143a790f876ca497d42e31dce2c0 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -14,7 +14,7 @@ use gpui::{Corner, List};
use heapless::Vec as ArrayVec;
use language_model::{LanguageModelEffortLevel, Speed};
use settings::update_settings_file;
-use ui::{ButtonLike, SplitButton, SplitButtonStyle, Tab};
+use ui::{ButtonLike, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab};
use workspace::SERIALIZATION_THROTTLE_TIME;
use super::*;
@@ -164,6 +164,46 @@ impl ThreadFeedbackState {
}
}
+struct GeneratingSpinner {
+ variant: SpinnerVariant,
+}
+
+impl GeneratingSpinner {
+ fn new(variant: SpinnerVariant) -> Self {
+ Self { variant }
+ }
+}
+
+impl Render for GeneratingSpinner {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+ SpinnerLabel::with_variant(self.variant).size(LabelSize::Small)
+ }
+}
+
+#[derive(IntoElement)]
+struct GeneratingSpinnerElement {
+ variant: SpinnerVariant,
+}
+
+impl GeneratingSpinnerElement {
+ fn new(variant: SpinnerVariant) -> Self {
+ Self { variant }
+ }
+}
+
+impl RenderOnce for GeneratingSpinnerElement {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let id = match self.variant {
+ SpinnerVariant::Dots => "generating-spinner-view",
+ SpinnerVariant::Sand => "confirmation-spinner-view",
+ _ => "spinner-view",
+ };
+ window.with_id(id, |window| {
+ window.use_state(cx, |_, _| GeneratingSpinner::new(self.variant))
+ })
+ }
+}
+
pub enum AcpThreadViewEvent {
FirstSendRequested { content: Vec },
MessageSentOrQueued,
@@ -344,7 +384,8 @@ impl ThreadView {
) -> Self {
let id = thread.read(cx).session_id().clone();
- let placeholder = placeholder_text(agent_display_name.as_ref(), false);
+ let has_commands = !session_capabilities.read().available_commands().is_empty();
+ let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
let history_subscription = history.as_ref().map(|h| {
cx.observe(h, |this, history, cx| {
@@ -868,7 +909,10 @@ impl ThreadView {
.upgrade()
.and_then(|workspace| workspace.read(cx).panel::(cx))
.is_some_and(|panel| {
- panel.read(cx).start_thread_in() == &StartThreadIn::NewWorktree
+ !matches!(
+ panel.read(cx).start_thread_in(),
+ StartThreadIn::LocalProject
+ )
});
if intercept_first_send {
@@ -3010,14 +3054,12 @@ impl ThreadView {
let is_done = thread.read(cx).status() == ThreadStatus::Idle;
let is_canceled_or_failed = self.is_subagent_canceled_or_failed(cx);
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
+
Some(
h_flex()
- .h(Tab::container_height(cx))
- .pl_2()
- .pr_1p5()
.w_full()
- .justify_between()
- .gap_1()
+ .h(Tab::container_height(cx))
.border_b_1()
.when(is_done && is_canceled_or_failed, |this| {
this.border_dashed()
@@ -3026,50 +3068,61 @@ impl ThreadView {
.bg(cx.theme().colors().editor_background.opacity(0.2))
.child(
h_flex()
- .flex_1()
- .gap_2()
+ .size_full()
+ .max_w(max_content_width)
+ .mx_auto()
+ .pl_2()
+ .pr_1()
+ .flex_shrink_0()
+ .justify_between()
+ .gap_1()
.child(
- Icon::new(IconName::ForwardArrowUp)
- .size(IconSize::Small)
- .color(Color::Muted),
+ h_flex()
+ .flex_1()
+ .gap_2()
+ .child(
+ Icon::new(IconName::ForwardArrowUp)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(self.title_editor.clone())
+ .when(is_done && is_canceled_or_failed, |this| {
+ this.child(Icon::new(IconName::Close).color(Color::Error))
+ })
+ .when(is_done && !is_canceled_or_failed, |this| {
+ this.child(Icon::new(IconName::Check).color(Color::Success))
+ }),
)
- .child(self.title_editor.clone())
- .when(is_done && is_canceled_or_failed, |this| {
- this.child(Icon::new(IconName::Close).color(Color::Error))
- })
- .when(is_done && !is_canceled_or_failed, |this| {
- this.child(Icon::new(IconName::Check).color(Color::Success))
- }),
- )
- .child(
- h_flex()
- .gap_0p5()
- .when(!is_done, |this| {
- this.child(
- IconButton::new("stop_subagent", IconName::Stop)
- .icon_size(IconSize::Small)
- .icon_color(Color::Error)
- .tooltip(Tooltip::text("Stop Subagent"))
- .on_click(move |_, _, cx| {
- thread.update(cx, |thread, cx| {
- thread.cancel(cx).detach();
- });
- }),
- )
- })
.child(
- IconButton::new("minimize_subagent", IconName::Minimize)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Minimize Subagent"))
- .on_click(move |_, window, cx| {
- let _ = server_view.update(cx, |server_view, cx| {
- server_view.navigate_to_session(
- parent_session_id.clone(),
- window,
- cx,
- );
- });
- }),
+ h_flex()
+ .gap_0p5()
+ .when(!is_done, |this| {
+ this.child(
+ IconButton::new("stop_subagent", IconName::Stop)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Error)
+ .tooltip(Tooltip::text("Stop Subagent"))
+ .on_click(move |_, _, cx| {
+ thread.update(cx, |thread, cx| {
+ thread.cancel(cx).detach();
+ });
+ }),
+ )
+ })
+ .child(
+ IconButton::new("minimize_subagent", IconName::Dash)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Minimize Subagent"))
+ .on_click(move |_, window, cx| {
+ let _ = server_view.update(cx, |server_view, cx| {
+ server_view.navigate_to_session(
+ parent_session_id.clone(),
+ window,
+ cx,
+ );
+ });
+ }),
+ ),
),
),
)
@@ -3088,13 +3141,15 @@ impl ThreadView {
let editor_bg_color = cx.theme().colors().editor_background;
let editor_expanded = self.editor_expanded;
let has_messages = self.list_state.item_count() > 0;
- let v2_empty_state = cx.has_flag::() && !has_messages;
+ let v2_empty_state = !has_messages;
let (expand_icon, expand_tooltip) = if editor_expanded {
(IconName::Minimize, "Minimize Message Editor")
} else {
(IconName::Maximize, "Expand Message Editor")
};
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
+
v_flex()
.on_action(cx.listener(Self::expand_message_editor))
.p_2()
@@ -3109,47 +3164,59 @@ impl ThreadView {
})
.child(
v_flex()
- .relative()
- .size_full()
- .when(v2_empty_state, |this| this.flex_1())
- .pt_1()
- .pr_2p5()
- .child(self.message_editor.clone())
- .when(!v2_empty_state, |this| {
- this.child(
- h_flex()
- .absolute()
- .top_0()
- .right_0()
- .opacity(0.5)
- .hover(|this| this.opacity(1.0))
- .child(
- IconButton::new("toggle-height", expand_icon)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip({
- move |_window, cx| {
- Tooltip::for_action_in(
- expand_tooltip,
- &ExpandMessageEditor,
- &focus_handle,
- cx,
- )
- }
- })
- .on_click(cx.listener(|this, _, window, cx| {
- this.expand_message_editor(
- &ExpandMessageEditor,
- window,
- cx,
- );
- })),
- ),
- )
- }),
+ .flex_1()
+ .min_h_0()
+ .w_full()
+ .max_w(max_content_width)
+ .mx_auto()
+ .child(
+ v_flex()
+ .relative()
+ .min_h_0()
+ .size_full()
+ .when(v2_empty_state, |this| this.flex_1())
+ .pt_1()
+ .pr_2p5()
+ .child(self.message_editor.clone())
+ .when(!v2_empty_state, |this| {
+ this.child(
+ h_flex()
+ .absolute()
+ .top_0()
+ .right_0()
+ .opacity(0.5)
+ .hover(|this| this.opacity(1.0))
+ .child(
+ IconButton::new("toggle-height", expand_icon)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip({
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ expand_tooltip,
+ &ExpandMessageEditor,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.expand_message_editor(
+ &ExpandMessageEditor,
+ window,
+ cx,
+ );
+ })),
+ ),
+ )
+ }),
+ ),
)
.child(
h_flex()
+ .w_full()
+ .max_w(max_content_width)
+ .mx_auto()
.flex_none()
.flex_wrap()
.justify_between()
@@ -4253,10 +4320,10 @@ impl Render for TokenUsageTooltip {
}
impl ThreadView {
- pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List {
+ fn render_entries(&mut self, cx: &mut Context) -> List {
list(
self.list_state.clone(),
- cx.processor(|this, index: usize, window, cx| {
+ cx.processor(move |this, index: usize, window, cx| {
let entries = this.thread.read(cx).entries();
if let Some(entry) = entries.get(index) {
this.render_entry(index, entries.len(), entry, window, cx)
@@ -5096,7 +5163,7 @@ impl ThreadView {
pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) {
let has_messages = self.list_state.item_count() > 0;
- let v2_empty_state = cx.has_flag::() && !has_messages;
+ let v2_empty_state = !has_messages;
let mode = if v2_empty_state {
EditorMode::Full {
@@ -5171,7 +5238,8 @@ impl ThreadView {
this.child(
h_flex()
.w_2()
- .child(SpinnerLabel::sand().size(LabelSize::Small)),
+ .justify_center()
+ .child(GeneratingSpinnerElement::new(SpinnerVariant::Sand)),
)
.child(
div().min_w(rems(8.)).child(
@@ -5183,7 +5251,12 @@ impl ThreadView {
} else if is_blocked_on_terminal_command {
this
} else {
- this.child(SpinnerLabel::new().size(LabelSize::Small))
+ this.child(
+ h_flex()
+ .w_2()
+ .justify_center()
+ .child(GeneratingSpinnerElement::new(SpinnerVariant::Dots)),
+ )
}
})
.when_some(elapsed_label, |this, elapsed| {
@@ -6325,7 +6398,6 @@ impl ThreadView {
.when(is_collapsible || failed_or_canceled, |this| {
let diff_for_discard = if has_revealed_diff
&& is_cancelled_edit
- && cx.has_flag::()
{
tool_call.diffs().next().cloned()
} else {
@@ -7389,9 +7461,8 @@ impl ThreadView {
.gap_2()
.map(|this| {
if card_layout {
- this.when(context_ix > 0, |this| {
- this.pt_2()
- .border_t_1()
+ this.p_2().when(context_ix > 0, |this| {
+ this.border_t_1()
.border_color(self.tool_card_border_color(cx))
})
} else {
@@ -8554,10 +8625,14 @@ impl ThreadView {
impl Render for ThreadView {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
let has_messages = self.list_state.item_count() > 0;
- let v2_empty_state = cx.has_flag::() && !has_messages;
+ let v2_empty_state = !has_messages;
+
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
let conversation = v_flex()
- .when(!v2_empty_state, |this| this.flex_1())
+ .mx_auto()
+ .max_w(max_content_width)
+ .when(!v2_empty_state, |this| this.flex_1().size_full())
.map(|this| {
let this = this.when(self.resumed_without_history, |this| {
this.child(Self::render_resume_notice(cx))
diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs
index 1b2ec0ad2fd460b4eec5a8b757bdd3058d4a3704..880257e3f942bf71d1d51b1e661d911474aa786b 100644
--- a/crates/agent_ui/src/mention_set.rs
+++ b/crates/agent_ui/src/mention_set.rs
@@ -18,7 +18,7 @@ use gpui::{
use http_client::{AsyncBody, HttpClientWithUrl};
use itertools::Either;
use language::Buffer;
-use language_model::LanguageModelImage;
+use language_model::{LanguageModelImage, LanguageModelImageExt};
use multi_buffer::MultiBufferRow;
use postage::stream::Stream as _;
use project::{Project, ProjectItem, ProjectPath, Worktree};
diff --git a/crates/agent_ui/src/mode_selector.rs b/crates/agent_ui/src/mode_selector.rs
index 60c9b8787092388ad2b3e2d5817834018dc7ea25..2b0754e9dc993c47fd32064219461df5304bad4d 100644
--- a/crates/agent_ui/src/mode_selector.rs
+++ b/crates/agent_ui/src/mode_selector.rs
@@ -1,17 +1,20 @@
use acp_thread::AgentSessionModes;
use agent_client_protocol as acp;
use agent_servers::AgentServer;
-use agent_settings::AgentSettings;
+
use fs::Fs;
use gpui::{Context, Entity, WeakEntity, Window, prelude::*};
-use settings::Settings as _;
+
use std::{rc::Rc, sync::Arc};
use ui::{
- Button, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
- PopoverMenuHandle, Tooltip, prelude::*,
+ Button, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip,
+ prelude::*,
};
-use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault};
+use crate::{
+ CycleModeSelector, ToggleProfileSelector,
+ ui::{HoldForDefault, documentation_aside_side},
+};
pub struct ModeSelector {
connection: Rc,
@@ -87,13 +90,7 @@ impl ModeSelector {
let current_mode = self.connection.current_mode();
let default_mode = self.agent_server.default_mode(cx);
- let settings = AgentSettings::get_global(cx);
- let side = match settings.dock {
- settings::DockPosition::Left => DocumentationSide::Right,
- settings::DockPosition::Bottom | settings::DockPosition::Right => {
- DocumentationSide::Left
- }
- };
+ let side = documentation_aside_side(cx);
for mode in all_modes {
let is_selected = &mode.id == ¤t_mode;
diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs
index 89ed3e490b33ca83cbdab25cfce77fee7cf9ccb6..89290bd9973216f04cdd1d70e442cf04a47b97f2 100644
--- a/crates/agent_ui/src/model_selector.rs
+++ b/crates/agent_ui/src/model_selector.rs
@@ -3,7 +3,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
use agent_client_protocol::ModelId;
use agent_servers::AgentServer;
-use agent_settings::AgentSettings;
+
use anyhow::Result;
use collections::{HashSet, IndexMap};
use fs::Fs;
@@ -16,12 +16,15 @@ use gpui::{
use itertools::Itertools;
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
-use settings::{Settings, SettingsStore};
-use ui::{DocumentationAside, DocumentationSide, IntoElement, prelude::*};
+use settings::SettingsStore;
+use ui::{DocumentationAside, IntoElement, prelude::*};
use util::ResultExt;
use zed_actions::agent::OpenSettings;
-use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
+use crate::ui::{
+ HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem,
+ documentation_aside_side,
+};
pub type ModelSelector = Picker;
@@ -385,13 +388,7 @@ impl PickerDelegate for ModelPickerDelegate {
let description = description.clone();
let is_default = *is_default;
- let settings = AgentSettings::get_global(cx);
- let side = match settings.dock {
- settings::DockPosition::Left => DocumentationSide::Right,
- settings::DockPosition::Bottom | settings::DockPosition::Right => {
- DocumentationSide::Left
- }
- };
+ let side = documentation_aside_side(cx);
DocumentationAside::new(
side,
diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs
index 963e32af55fda90f49edb0787f7327190c92681f..a73f78b1a5a91a7cd564fd498ad3932019c18821 100644
--- a/crates/agent_ui/src/profile_selector.rs
+++ b/crates/agent_ui/src/profile_selector.rs
@@ -1,4 +1,6 @@
-use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector};
+use crate::{
+ CycleModeSelector, ManageProfiles, ToggleProfileSelector, ui::documentation_aside_side,
+};
use agent_settings::{
AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles,
};
@@ -15,8 +17,8 @@ use std::{
sync::{Arc, atomic::AtomicBool},
};
use ui::{
- DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem,
- ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
+ DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing,
+ PopoverMenuHandle, Tooltip, prelude::*,
};
/// Trait for types that can provide and manage agent profiles
@@ -629,13 +631,7 @@ impl PickerDelegate for ProfilePickerDelegate {
let candidate = self.candidates.get(entry.candidate_index)?;
let docs_aside = Self::documentation(candidate)?.to_string();
- let settings = AgentSettings::get_global(cx);
- let side = match settings.dock {
- settings::DockPosition::Left => DocumentationSide::Right,
- settings::DockPosition::Bottom | settings::DockPosition::Right => {
- DocumentationSide::Left
- }
- };
+ let side = documentation_aside_side(cx);
Some(DocumentationAside {
side,
diff --git a/crates/agent_ui/src/thread_branch_picker.rs b/crates/agent_ui/src/thread_branch_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..99aced11de951c158b1c84c1f28c69da85a05359
--- /dev/null
+++ b/crates/agent_ui/src/thread_branch_picker.rs
@@ -0,0 +1,758 @@
+use std::collections::{HashMap, HashSet};
+use std::rc::Rc;
+
+use collections::HashSet as CollectionsHashSet;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use fuzzy::StringMatchCandidate;
+use git::repository::Branch as GitBranch;
+use gpui::{
+ AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+ IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::Project;
+use ui::{
+ Divider, DocumentationAside, HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem,
+ ListItemSpacing, prelude::*,
+};
+use util::ResultExt as _;
+
+use crate::{NewWorktreeBranchTarget, StartThreadIn};
+
+pub(crate) struct ThreadBranchPicker {
+ picker: Entity>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadBranchPicker {
+ pub fn new(
+ project: Entity,
+ current_target: &StartThreadIn,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let project_worktree_paths: HashSet = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
+ let current_branch_name = project
+ .read(cx)
+ .active_repository(cx)
+ .and_then(|repo| {
+ repo.read(cx)
+ .branch
+ .as_ref()
+ .map(|branch| branch.name().to_string())
+ })
+ .unwrap_or_else(|| "HEAD".to_string());
+
+ let repository = if has_multiple_repositories {
+ None
+ } else {
+ project.read(cx).active_repository(cx)
+ };
+ let branches_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.branches()));
+ let default_branch_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
+ let worktrees_request = repository.map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
+
+ let (worktree_name, branch_target) = match current_target {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target,
+ } => (worktree_name.clone(), branch_target.clone()),
+ _ => (None, NewWorktreeBranchTarget::default()),
+ };
+
+ let delegate = ThreadBranchPickerDelegate {
+ matches: vec![ThreadBranchEntry::CurrentBranch],
+ all_branches: None,
+ occupied_branches: None,
+ selected_index: 0,
+ worktree_name,
+ branch_target,
+ project_worktree_paths,
+ current_branch_name,
+ default_branch_name: None,
+ has_multiple_repositories,
+ };
+
+ let picker = cx.new(|cx| {
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
+ .modal(false)
+ .max_height(Some(rems(20.).into()))
+ });
+
+ let focus_handle = picker.focus_handle(cx);
+
+ if let (Some(branches_request), Some(default_branch_request), Some(worktrees_request)) =
+ (branches_request, default_branch_request, worktrees_request)
+ {
+ let picker_handle = picker.downgrade();
+ cx.spawn_in(window, async move |_this, cx| {
+ let branches = branches_request.await??;
+ let default_branch = default_branch_request.await.ok().and_then(Result::ok).flatten();
+ let worktrees = worktrees_request.await??;
+
+ let remote_upstreams: CollectionsHashSet<_> = branches
+ .iter()
+ .filter_map(|branch| {
+ branch
+ .upstream
+ .as_ref()
+ .filter(|upstream| upstream.is_remote())
+ .map(|upstream| upstream.ref_name.clone())
+ })
+ .collect();
+
+ let mut occupied_branches = HashMap::new();
+ for worktree in worktrees {
+ let Some(branch_name) = worktree.branch_name().map(ToOwned::to_owned) else {
+ continue;
+ };
+
+ let reason = if picker_handle
+ .read_with(cx, |picker, _| {
+ picker
+ .delegate
+ .project_worktree_paths
+ .contains(&worktree.path)
+ })
+ .unwrap_or(false)
+ {
+ format!(
+ "This branch is already checked out in the current project worktree at {}.",
+ worktree.path.display()
+ )
+ } else {
+ format!(
+ "This branch is already checked out in a linked worktree at {}.",
+ worktree.path.display()
+ )
+ };
+
+ occupied_branches.insert(branch_name, reason);
+ }
+
+ let mut all_branches: Vec<_> = branches
+ .into_iter()
+ .filter(|branch| !remote_upstreams.contains(&branch.ref_name))
+ .collect();
+ all_branches.sort_by_key(|branch| {
+ (
+ branch.is_remote(),
+ !branch.is_head,
+ branch
+ .most_recent_commit
+ .as_ref()
+ .map(|commit| 0 - commit.commit_timestamp),
+ )
+ });
+
+ picker_handle.update_in(cx, |picker, window, cx| {
+ picker.delegate.all_branches = Some(all_branches);
+ picker.delegate.occupied_branches = Some(occupied_branches);
+ picker.delegate.default_branch_name = default_branch.map(|branch| branch.to_string());
+ picker.refresh(window, cx);
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ let subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ });
+
+ Self {
+ picker,
+ focus_handle,
+ _subscription: subscription,
+ }
+ }
+}
+
+impl Focusable for ThreadBranchPicker {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter for ThreadBranchPicker {}
+
+impl Render for ThreadBranchPicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .w(rems(22.))
+ .elevation_3(cx)
+ .child(self.picker.clone())
+ .on_mouse_down_out(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ }
+}
+
+#[derive(Clone)]
+enum ThreadBranchEntry {
+ CurrentBranch,
+ DefaultBranch,
+ Separator,
+ ExistingBranch {
+ branch: GitBranch,
+ positions: Vec,
+ },
+ CreateNamed {
+ name: String,
+ },
+}
+
+pub(crate) struct ThreadBranchPickerDelegate {
+ matches: Vec,
+ all_branches: Option>,
+ occupied_branches: Option>,
+ selected_index: usize,
+ worktree_name: Option,
+ branch_target: NewWorktreeBranchTarget,
+ project_worktree_paths: HashSet,
+ current_branch_name: String,
+ default_branch_name: Option,
+ has_multiple_repositories: bool,
+}
+
+impl ThreadBranchPickerDelegate {
+ fn new_worktree_action(&self, branch_target: NewWorktreeBranchTarget) -> StartThreadIn {
+ StartThreadIn::NewWorktree {
+ worktree_name: self.worktree_name.clone(),
+ branch_target,
+ }
+ }
+
+ fn selected_entry_name(&self) -> Option<&str> {
+ match &self.branch_target {
+ NewWorktreeBranchTarget::CurrentBranch => None,
+ NewWorktreeBranchTarget::ExistingBranch { name } => Some(name),
+ NewWorktreeBranchTarget::CreateBranch {
+ from_ref: Some(from_ref),
+ ..
+ } => Some(from_ref),
+ NewWorktreeBranchTarget::CreateBranch { name, .. } => Some(name),
+ }
+ }
+
+ fn prefer_create_entry(&self) -> bool {
+ matches!(
+ &self.branch_target,
+ NewWorktreeBranchTarget::CreateBranch { from_ref: None, .. }
+ )
+ }
+
+ fn fixed_matches(&self) -> Vec {
+ let mut matches = vec![ThreadBranchEntry::CurrentBranch];
+ if !self.has_multiple_repositories
+ && self
+ .default_branch_name
+ .as_ref()
+ .is_some_and(|default_branch_name| default_branch_name != &self.current_branch_name)
+ {
+ matches.push(ThreadBranchEntry::DefaultBranch);
+ }
+ matches
+ }
+
+ fn is_branch_occupied(&self, branch_name: &str) -> bool {
+ self.occupied_branches
+ .as_ref()
+ .is_some_and(|occupied| occupied.contains_key(branch_name))
+ }
+
+ fn branch_aside_text(&self, branch_name: &str, is_remote: bool) -> Option {
+ if self.is_branch_occupied(branch_name) {
+ Some(
+ format!(
+ "This branch is already checked out in another worktree. \
+ A new branch will be created from {branch_name}."
+ )
+ .into(),
+ )
+ } else if is_remote {
+ Some("A new local branch will be created from this remote branch.".into())
+ } else {
+ None
+ }
+ }
+
+ fn entry_aside_text(&self, entry: &ThreadBranchEntry) -> Option {
+ match entry {
+ ThreadBranchEntry::CurrentBranch => Some(SharedString::from(
+ "A new branch will be created from the current branch.",
+ )),
+ ThreadBranchEntry::DefaultBranch => {
+ let default_branch_name = self
+ .default_branch_name
+ .as_ref()
+ .filter(|name| *name != &self.current_branch_name)?;
+ self.branch_aside_text(default_branch_name, false)
+ }
+ ThreadBranchEntry::ExistingBranch { branch, .. } => {
+ self.branch_aside_text(branch.name(), branch.is_remote())
+ }
+ _ => None,
+ }
+ }
+
+ fn sync_selected_index(&mut self) {
+ let selected_entry_name = self.selected_entry_name().map(ToOwned::to_owned);
+ let prefer_create = self.prefer_create_entry();
+
+ if prefer_create {
+ if let Some(ref selected_entry_name) = selected_entry_name {
+ if let Some(index) = self.matches.iter().position(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::CreateNamed { name } if name == selected_entry_name
+ )
+ }) {
+ self.selected_index = index;
+ return;
+ }
+ }
+ } else if let Some(ref selected_entry_name) = selected_entry_name {
+ if selected_entry_name == &self.current_branch_name {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadBranchEntry::CurrentBranch))
+ {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if self
+ .default_branch_name
+ .as_ref()
+ .is_some_and(|default_branch_name| default_branch_name == selected_entry_name)
+ {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadBranchEntry::DefaultBranch))
+ {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if let Some(index) = self.matches.iter().position(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::ExistingBranch { branch, .. }
+ if branch.name() == selected_entry_name.as_str()
+ )
+ }) {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if self.matches.len() > 1
+ && self
+ .matches
+ .iter()
+ .skip(1)
+ .all(|entry| matches!(entry, ThreadBranchEntry::CreateNamed { .. }))
+ {
+ self.selected_index = 1;
+ return;
+ }
+
+ self.selected_index = 0;
+ }
+}
+
+impl PickerDelegate for ThreadBranchPickerDelegate {
+ type ListItem = AnyElement;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc {
+ "Search branches…".into()
+ }
+
+ fn editor_position(&self) -> PickerEditorPosition {
+ PickerEditorPosition::Start
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool {
+ !matches!(self.matches.get(ix), Some(ThreadBranchEntry::Separator))
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> Task<()> {
+ if self.has_multiple_repositories {
+ let mut matches = self.fixed_matches();
+
+ if query.is_empty() {
+ if let Some(name) = self.selected_entry_name().map(ToOwned::to_owned) {
+ if self.prefer_create_entry() {
+ matches.push(ThreadBranchEntry::Separator);
+ matches.push(ThreadBranchEntry::CreateNamed { name });
+ }
+ }
+ } else {
+ matches.push(ThreadBranchEntry::Separator);
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: query.replace(' ', "-"),
+ });
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+ return Task::ready(());
+ }
+
+ let Some(all_branches) = self.all_branches.clone() else {
+ self.matches = self.fixed_matches();
+ self.selected_index = 0;
+ return Task::ready(());
+ };
+
+ if query.is_empty() {
+ let mut matches = self.fixed_matches();
+ let filtered_branches: Vec<_> = all_branches
+ .into_iter()
+ .filter(|branch| {
+ branch.name() != self.current_branch_name
+ && self
+ .default_branch_name
+ .as_ref()
+ .is_none_or(|default_branch_name| branch.name() != default_branch_name)
+ })
+ .collect();
+
+ if !filtered_branches.is_empty() {
+ matches.push(ThreadBranchEntry::Separator);
+ }
+ for branch in filtered_branches {
+ matches.push(ThreadBranchEntry::ExistingBranch {
+ branch,
+ positions: Vec::new(),
+ });
+ }
+
+ if let Some(selected_entry_name) = self.selected_entry_name().map(ToOwned::to_owned) {
+ let has_existing = matches.iter().any(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::ExistingBranch { branch, .. }
+ if branch.name() == selected_entry_name
+ )
+ });
+ if self.prefer_create_entry() && !has_existing {
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: selected_entry_name,
+ });
+ }
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+ return Task::ready(());
+ }
+
+ let candidates: Vec<_> = all_branches
+ .iter()
+ .enumerate()
+ .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
+ .collect();
+ let executor = cx.background_executor().clone();
+ let query_clone = query.clone();
+ let normalized_query = query.replace(' ', "-");
+
+ let task = cx.background_executor().spawn(async move {
+ fuzzy::match_strings(
+ &candidates,
+ &query_clone,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ executor,
+ )
+ .await
+ });
+
+ let all_branches_clone = all_branches;
+ cx.spawn_in(window, async move |picker, cx| {
+ let fuzzy_matches = task.await;
+
+ picker
+ .update_in(cx, |picker, _window, cx| {
+ let mut matches = picker.delegate.fixed_matches();
+ let mut has_dynamic_entries = false;
+
+ for candidate in &fuzzy_matches {
+ let branch = all_branches_clone[candidate.candidate_id].clone();
+ if branch.name() == picker.delegate.current_branch_name
+ || picker.delegate.default_branch_name.as_ref().is_some_and(
+ |default_branch_name| branch.name() == default_branch_name,
+ )
+ {
+ continue;
+ }
+ if !has_dynamic_entries {
+ matches.push(ThreadBranchEntry::Separator);
+ has_dynamic_entries = true;
+ }
+ matches.push(ThreadBranchEntry::ExistingBranch {
+ branch,
+ positions: candidate.positions.clone(),
+ });
+ }
+
+ if fuzzy_matches.is_empty() {
+ if !has_dynamic_entries {
+ matches.push(ThreadBranchEntry::Separator);
+ }
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: normalized_query.clone(),
+ });
+ }
+
+ picker.delegate.matches = matches;
+ if let Some(index) =
+ picker.delegate.matches.iter().position(|entry| {
+ matches!(entry, ThreadBranchEntry::ExistingBranch { .. })
+ })
+ {
+ picker.delegate.selected_index = index;
+ } else if !fuzzy_matches.is_empty() {
+ picker.delegate.selected_index = 0;
+ } else if let Some(index) =
+ picker.delegate.matches.iter().position(|entry| {
+ matches!(entry, ThreadBranchEntry::CreateNamed { .. })
+ })
+ {
+ picker.delegate.selected_index = index;
+ } else {
+ picker.delegate.sync_selected_index();
+ }
+ cx.notify();
+ })
+ .log_err();
+ })
+ }
+
+ fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) {
+ let Some(entry) = self.matches.get(self.selected_index) else {
+ return;
+ };
+
+ match entry {
+ ThreadBranchEntry::Separator => return,
+ ThreadBranchEntry::CurrentBranch => {
+ window.dispatch_action(
+ Box::new(self.new_worktree_action(NewWorktreeBranchTarget::CurrentBranch)),
+ cx,
+ );
+ }
+ ThreadBranchEntry::DefaultBranch => {
+ let Some(default_branch_name) = self.default_branch_name.clone() else {
+ return;
+ };
+ window.dispatch_action(
+ Box::new(
+ self.new_worktree_action(NewWorktreeBranchTarget::ExistingBranch {
+ name: default_branch_name,
+ }),
+ ),
+ cx,
+ );
+ }
+ ThreadBranchEntry::ExistingBranch { branch, .. } => {
+ let branch_target = if branch.is_remote() {
+ let branch_name = branch
+ .ref_name
+ .as_ref()
+ .strip_prefix("refs/remotes/")
+ .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
+ .unwrap_or(branch.name())
+ .to_string();
+ NewWorktreeBranchTarget::CreateBranch {
+ name: branch_name,
+ from_ref: Some(branch.name().to_string()),
+ }
+ } else {
+ NewWorktreeBranchTarget::ExistingBranch {
+ name: branch.name().to_string(),
+ }
+ };
+ window.dispatch_action(Box::new(self.new_worktree_action(branch_target)), cx);
+ }
+ ThreadBranchEntry::CreateNamed { name } => {
+ window.dispatch_action(
+ Box::new(
+ self.new_worktree_action(NewWorktreeBranchTarget::CreateBranch {
+ name: name.clone(),
+ from_ref: None,
+ }),
+ ),
+ cx,
+ );
+ }
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {}
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(ix)?;
+
+ match entry {
+ ThreadBranchEntry::Separator => Some(
+ div()
+ .py(DynamicSpacing::Base04.rems(cx))
+ .child(Divider::horizontal())
+ .into_any_element(),
+ ),
+ ThreadBranchEntry::CurrentBranch => {
+ let branch_name = if self.has_multiple_repositories {
+ SharedString::from("current branches")
+ } else {
+ SharedString::from(self.current_branch_name.clone())
+ };
+
+ Some(
+ ListItem::new("current-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(Label::new(branch_name))
+ .into_any_element(),
+ )
+ }
+ ThreadBranchEntry::DefaultBranch => {
+ let default_branch_name = self
+ .default_branch_name
+ .as_ref()
+ .filter(|name| *name != &self.current_branch_name)?;
+ let is_occupied = self.is_branch_occupied(default_branch_name);
+
+ let item = ListItem::new("default-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(Label::new(default_branch_name.clone()));
+
+ Some(
+ if is_occupied {
+ item.start_slot(Icon::new(IconName::GitBranchPlus).color(Color::Muted))
+ } else {
+ item
+ }
+ .into_any_element(),
+ )
+ }
+ ThreadBranchEntry::ExistingBranch {
+ branch, positions, ..
+ } => {
+ let branch_name = branch.name().to_string();
+ let needs_new_branch = self.is_branch_occupied(&branch_name) || branch.is_remote();
+
+ Some(
+ ListItem::new(SharedString::from(format!("branch-{ix}")))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(
+ h_flex()
+ .min_w_0()
+ .gap_1()
+ .child(
+ HighlightedLabel::new(branch_name, positions.clone())
+ .truncate(),
+ )
+ .when(needs_new_branch, |item| {
+ item.child(
+ Icon::new(IconName::GitBranchPlus)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ }),
+ )
+ .into_any_element(),
+ )
+ }
+ ThreadBranchEntry::CreateNamed { name } => Some(
+ ListItem::new("create-named-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(Label::new(format!("Create Branch: \"{name}\"…")))
+ .into_any_element(),
+ ),
+ }
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option {
+ None
+ }
+
+ fn documentation_aside(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(self.selected_index)?;
+ let aside_text = self.entry_aside_text(entry)?;
+ let side = crate::ui::documentation_aside_side(cx);
+
+ Some(DocumentationAside::new(
+ side,
+ Rc::new(move |_| Label::new(aside_text.clone()).into_any_element()),
+ ))
+ }
+
+ fn documentation_aside_index(&self) -> Option {
+ let entry = self.matches.get(self.selected_index)?;
+ self.entry_aside_text(entry).map(|_| self.selected_index)
+ }
+}
diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs
index 6258f34630b75768161ba6e249f7f9ea3fc3c4ac..1eddd3b9a1eafc3bd63f93a8a3ef637626d18bb2 100644
--- a/crates/agent_ui/src/thread_metadata_store.rs
+++ b/crates/agent_ui/src/thread_metadata_store.rs
@@ -16,7 +16,6 @@ use db::{
},
sqlez_macros::sql,
};
-use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use futures::{FutureExt as _, future::Shared};
use gpui::{AppContext as _, Entity, Global, Subscription, Task};
use project::AgentId;
@@ -28,16 +27,7 @@ use crate::DEFAULT_THREAD_TITLE;
pub fn init(cx: &mut App) {
ThreadMetadataStore::init_global(cx);
-
- if cx.has_flag::() {
- migrate_thread_metadata(cx);
- }
- cx.observe_flag::(|has_flag, cx| {
- if has_flag {
- migrate_thread_metadata(cx);
- }
- })
- .detach();
+ migrate_thread_metadata(cx);
}
/// Migrate existing thread metadata from native agent thread store to the new metadata storage.
@@ -344,10 +334,6 @@ impl ThreadMetadataStore {
}
pub fn save_all(&mut self, metadata: Vec, cx: &mut Context) {
- if !cx.has_flag::() {
- return;
- }
-
for metadata in metadata {
self.save_internal(metadata);
}
@@ -360,10 +346,6 @@ impl ThreadMetadataStore {
}
fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context) {
- if !cx.has_flag::() {
- return;
- }
-
self.save_internal(metadata);
cx.notify();
}
@@ -413,10 +395,6 @@ impl ThreadMetadataStore {
work_dirs: PathList,
cx: &mut Context,
) {
- if !cx.has_flag::() {
- return;
- }
-
if let Some(thread) = self.threads.get(session_id) {
self.save_internal(ThreadMetadata {
folder_paths: work_dirs,
@@ -538,10 +516,6 @@ impl ThreadMetadataStore {
archived: bool,
cx: &mut Context,
) {
- if !cx.has_flag::() {
- return;
- }
-
if let Some(thread) = self.threads.get(session_id) {
self.save_internal(ThreadMetadata {
archived,
@@ -552,10 +526,6 @@ impl ThreadMetadataStore {
}
pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context) {
- if !cx.has_flag::() {
- return;
- }
-
if let Some(thread) = self.threads.get(&session_id) {
if let Some(session_ids) = self.threads_by_paths.get_mut(&thread.folder_paths) {
session_ids.remove(&session_id);
@@ -712,19 +682,12 @@ impl ThreadMetadataStore {
PathList::new(&paths)
};
- let main_worktree_paths = {
- let project = thread_ref.project().read(cx);
- let mut main_paths: Vec> = Vec::new();
- for repo in project.repositories(cx).values() {
- let snapshot = repo.read(cx).snapshot();
- if snapshot.is_linked_worktree() {
- main_paths.push(snapshot.original_repo_abs_path.clone());
- }
- }
- main_paths.sort();
- main_paths.dedup();
- PathList::new(&main_paths)
- };
+ let main_worktree_paths = thread_ref
+ .project()
+ .read(cx)
+ .project_group_key(cx)
+ .path_list()
+ .clone();
// Threads without a folder path (e.g. started in an empty
// window) are archived by default so they don't get lost,
@@ -1053,7 +1016,7 @@ mod tests {
use action_log::ActionLog;
use agent::DbThread;
use agent_client_protocol as acp;
- use feature_flags::FeatureFlagAppExt;
+
use gpui::TestAppContext;
use project::FakeFs;
use project::Project;
@@ -1103,7 +1066,6 @@ mod tests {
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
ThreadMetadataStore::init_global(cx);
ThreadStore::init_global(cx);
});
@@ -1144,7 +1106,6 @@ mod tests {
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
- cx.update_flags(true, vec!["agent-v2".to_string()]);
ThreadMetadataStore::init_global(cx);
});
diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..142f47f02ffd282409c86413a390ae9359a0f8dc
--- /dev/null
+++ b/crates/agent_ui/src/thread_worktree_picker.rs
@@ -0,0 +1,621 @@
+use std::path::PathBuf;
+use std::rc::Rc;
+use std::sync::Arc;
+
+use agent_settings::AgentSettings;
+use fs::Fs;
+use fuzzy::StringMatchCandidate;
+use git::repository::Worktree as GitWorktree;
+use gpui::{
+ AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+ IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::{Project, git_store::RepositoryId};
+use settings::{NewThreadLocation, Settings, update_settings_file};
+use ui::{
+ Divider, DocumentationAside, HighlightedLabel, Label, LabelCommon, ListItem, ListItemSpacing,
+ Tooltip, prelude::*,
+};
+use util::ResultExt as _;
+use util::paths::PathExt;
+
+use crate::ui::HoldForDefault;
+use crate::{NewWorktreeBranchTarget, StartThreadIn};
+
+pub(crate) struct ThreadWorktreePicker {
+ picker: Entity>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadWorktreePicker {
+ pub fn new(
+ project: Entity,
+ current_target: &StartThreadIn,
+ fs: Arc,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let project_worktree_paths: Vec = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .map(|wt| wt.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ let preserved_branch_target = match current_target {
+ StartThreadIn::NewWorktree { branch_target, .. } => branch_target.clone(),
+ _ => NewWorktreeBranchTarget::default(),
+ };
+
+ let all_worktrees: Vec<_> = project
+ .read(cx)
+ .repositories(cx)
+ .iter()
+ .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone()))
+ .collect();
+
+ let has_multiple_repositories = all_worktrees.len() > 1;
+
+ let linked_worktrees: Vec<_> = if has_multiple_repositories {
+ Vec::new()
+ } else {
+ all_worktrees
+ .iter()
+ .flat_map(|(_, worktrees)| worktrees.iter())
+ .filter(|worktree| {
+ !project_worktree_paths
+ .iter()
+ .any(|project_path| project_path == &worktree.path)
+ })
+ .cloned()
+ .collect()
+ };
+
+ let mut initial_matches = vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ];
+
+ if !linked_worktrees.is_empty() {
+ initial_matches.push(ThreadWorktreeEntry::Separator);
+ for worktree in &linked_worktrees {
+ initial_matches.push(ThreadWorktreeEntry::LinkedWorktree {
+ worktree: worktree.clone(),
+ positions: Vec::new(),
+ });
+ }
+ }
+
+ let selected_index = match current_target {
+ StartThreadIn::LocalProject => 0,
+ StartThreadIn::NewWorktree { .. } => 1,
+ StartThreadIn::LinkedWorktree { path, .. } => initial_matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { worktree, .. } if worktree.path == *path))
+ .unwrap_or(0),
+ };
+
+ let delegate = ThreadWorktreePickerDelegate {
+ matches: initial_matches,
+ all_worktrees,
+ project_worktree_paths,
+ selected_index,
+ project,
+ preserved_branch_target,
+ fs,
+ };
+
+ let picker = cx.new(|cx| {
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
+ .modal(false)
+ .max_height(Some(rems(20.).into()))
+ });
+
+ let subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ });
+
+ Self {
+ focus_handle: picker.focus_handle(cx),
+ picker,
+ _subscription: subscription,
+ }
+ }
+}
+
+impl Focusable for ThreadWorktreePicker {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter for ThreadWorktreePicker {}
+
+impl Render for ThreadWorktreePicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .w(rems(20.))
+ .elevation_3(cx)
+ .child(self.picker.clone())
+ .on_mouse_down_out(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ }
+}
+
+#[derive(Clone)]
+enum ThreadWorktreeEntry {
+ CurrentWorktree,
+ NewWorktree,
+ Separator,
+ LinkedWorktree {
+ worktree: GitWorktree,
+ positions: Vec,
+ },
+ CreateNamed {
+ name: String,
+ disabled_reason: Option,
+ },
+}
+
+pub(crate) struct ThreadWorktreePickerDelegate {
+ matches: Vec,
+ all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>,
+ project_worktree_paths: Vec,
+ selected_index: usize,
+ preserved_branch_target: NewWorktreeBranchTarget,
+ project: Entity,
+ fs: Arc,
+}
+
+impl ThreadWorktreePickerDelegate {
+ fn new_worktree_action(&self, worktree_name: Option) -> StartThreadIn {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target: self.preserved_branch_target.clone(),
+ }
+ }
+
+ fn sync_selected_index(&mut self, has_query: bool) {
+ if !has_query {
+ return;
+ }
+
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { .. }))
+ {
+ self.selected_index = index;
+ } else if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
+ {
+ self.selected_index = index;
+ } else {
+ self.selected_index = 0;
+ }
+ }
+}
+
+impl PickerDelegate for ThreadWorktreePickerDelegate {
+ type ListItem = AnyElement;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc {
+ "Search or create worktrees…".into()
+ }
+
+ fn editor_position(&self) -> PickerEditorPosition {
+ PickerEditorPosition::Start
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool {
+ !matches!(self.matches.get(ix), Some(ThreadWorktreeEntry::Separator))
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> Task<()> {
+ let has_multiple_repositories = self.all_worktrees.len() > 1;
+
+ let linked_worktrees: Vec<_> = if has_multiple_repositories {
+ Vec::new()
+ } else {
+ self.all_worktrees
+ .iter()
+ .flat_map(|(_, worktrees)| worktrees.iter())
+ .filter(|worktree| {
+ !self
+ .project_worktree_paths
+ .iter()
+ .any(|project_path| project_path == &worktree.path)
+ })
+ .cloned()
+ .collect()
+ };
+
+ let normalized_query = query.replace(' ', "-");
+ let has_named_worktree = self.all_worktrees.iter().any(|(_, worktrees)| {
+ worktrees
+ .iter()
+ .any(|worktree| worktree.display_name() == normalized_query)
+ });
+ let create_named_disabled_reason = if has_multiple_repositories {
+ Some("Cannot create a named worktree in a project with multiple repositories".into())
+ } else if has_named_worktree {
+ Some("A worktree with this name already exists".into())
+ } else {
+ None
+ };
+
+ let mut matches = vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ];
+
+ if query.is_empty() {
+ if !linked_worktrees.is_empty() {
+ matches.push(ThreadWorktreeEntry::Separator);
+ }
+ for worktree in &linked_worktrees {
+ matches.push(ThreadWorktreeEntry::LinkedWorktree {
+ worktree: worktree.clone(),
+ positions: Vec::new(),
+ });
+ }
+ } else if linked_worktrees.is_empty() {
+ matches.push(ThreadWorktreeEntry::Separator);
+ matches.push(ThreadWorktreeEntry::CreateNamed {
+ name: normalized_query,
+ disabled_reason: create_named_disabled_reason,
+ });
+ } else {
+ let candidates: Vec<_> = linked_worktrees
+ .iter()
+ .enumerate()
+ .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.display_name()))
+ .collect();
+
+ let executor = cx.background_executor().clone();
+ let query_clone = query.clone();
+
+ let task = cx.background_executor().spawn(async move {
+ fuzzy::match_strings(
+ &candidates,
+ &query_clone,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ executor,
+ )
+ .await
+ });
+
+ let linked_worktrees_clone = linked_worktrees;
+ return cx.spawn_in(window, async move |picker, cx| {
+ let fuzzy_matches = task.await;
+
+ picker
+ .update_in(cx, |picker, _window, cx| {
+ let mut new_matches = vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ];
+
+ let has_extra_entries = !fuzzy_matches.is_empty();
+
+ if has_extra_entries {
+ new_matches.push(ThreadWorktreeEntry::Separator);
+ }
+
+ for candidate in &fuzzy_matches {
+ new_matches.push(ThreadWorktreeEntry::LinkedWorktree {
+ worktree: linked_worktrees_clone[candidate.candidate_id].clone(),
+ positions: candidate.positions.clone(),
+ });
+ }
+
+ let has_exact_match = linked_worktrees_clone
+ .iter()
+ .any(|worktree| worktree.display_name() == query);
+
+ if !has_exact_match {
+ if !has_extra_entries {
+ new_matches.push(ThreadWorktreeEntry::Separator);
+ }
+ new_matches.push(ThreadWorktreeEntry::CreateNamed {
+ name: normalized_query.clone(),
+ disabled_reason: create_named_disabled_reason.clone(),
+ });
+ }
+
+ picker.delegate.matches = new_matches;
+ picker.delegate.sync_selected_index(true);
+
+ cx.notify();
+ })
+ .log_err();
+ });
+ }
+
+ self.matches = matches;
+ self.sync_selected_index(!query.is_empty());
+
+ Task::ready(())
+ }
+
+ fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) {
+ let Some(entry) = self.matches.get(self.selected_index) else {
+ return;
+ };
+
+ match entry {
+ ThreadWorktreeEntry::Separator => return,
+ ThreadWorktreeEntry::CurrentWorktree => {
+ if secondary {
+ update_settings_file(self.fs.clone(), cx, |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_new_thread_location(NewThreadLocation::LocalProject);
+ });
+ }
+ window.dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
+ }
+ ThreadWorktreeEntry::NewWorktree => {
+ if secondary {
+ update_settings_file(self.fs.clone(), cx, |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_new_thread_location(NewThreadLocation::NewWorktree);
+ });
+ }
+ window.dispatch_action(Box::new(self.new_worktree_action(None)), cx);
+ }
+ ThreadWorktreeEntry::LinkedWorktree { worktree, .. } => {
+ window.dispatch_action(
+ Box::new(StartThreadIn::LinkedWorktree {
+ path: worktree.path.clone(),
+ display_name: worktree.display_name().to_string(),
+ }),
+ cx,
+ );
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ name,
+ disabled_reason: None,
+ } => {
+ window.dispatch_action(Box::new(self.new_worktree_action(Some(name.clone()))), cx);
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ disabled_reason: Some(_),
+ ..
+ } => {
+ return;
+ }
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {}
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(ix)?;
+ let project = self.project.read(cx);
+ let is_new_worktree_disabled =
+ project.repositories(cx).is_empty() || project.is_via_collab();
+
+ match entry {
+ ThreadWorktreeEntry::Separator => Some(
+ div()
+ .py(DynamicSpacing::Base04.rems(cx))
+ .child(Divider::horizontal())
+ .into_any_element(),
+ ),
+ ThreadWorktreeEntry::CurrentWorktree => {
+ let path_label = project.active_repository(cx).map(|repo| {
+ let path = repo.read(cx).work_directory_abs_path.clone();
+ path.compact().to_string_lossy().to_string()
+ });
+
+ Some(
+ ListItem::new("current-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(
+ v_flex()
+ .min_w_0()
+ .overflow_hidden()
+ .child(Label::new("Current Worktree"))
+ .when_some(path_label, |this, path| {
+ this.child(
+ Label::new(path)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate_start(),
+ )
+ }),
+ )
+ .into_any_element(),
+ )
+ }
+ ThreadWorktreeEntry::NewWorktree => {
+ let item = ListItem::new("new-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .disabled(is_new_worktree_disabled)
+ .child(
+ v_flex()
+ .min_w_0()
+ .overflow_hidden()
+ .child(
+ Label::new("New Git Worktree")
+ .when(is_new_worktree_disabled, |this| {
+ this.color(Color::Disabled)
+ }),
+ )
+ .child(
+ Label::new("Get a fresh new worktree")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ );
+
+ Some(
+ if is_new_worktree_disabled {
+ item.tooltip(Tooltip::text("Requires a Git repository in the project"))
+ } else {
+ item
+ }
+ .into_any_element(),
+ )
+ }
+ ThreadWorktreeEntry::LinkedWorktree {
+ worktree,
+ positions,
+ } => {
+ let display_name = worktree.display_name();
+ let first_line = display_name.lines().next().unwrap_or(display_name);
+ let positions: Vec<_> = positions
+ .iter()
+ .copied()
+ .filter(|&pos| pos < first_line.len())
+ .collect();
+ let path = worktree.path.compact();
+
+ Some(
+ ListItem::new(SharedString::from(format!("linked-worktree-{ix}")))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(
+ v_flex()
+ .min_w_0()
+ .overflow_hidden()
+ .child(
+ HighlightedLabel::new(first_line.to_owned(), positions)
+ .truncate(),
+ )
+ .child(
+ Label::new(path.to_string_lossy().to_string())
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate_start(),
+ ),
+ )
+ .into_any_element(),
+ )
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ name,
+ disabled_reason,
+ } => {
+ let is_disabled = disabled_reason.is_some();
+ let item = ListItem::new("create-named-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .disabled(is_disabled)
+ .child(Label::new(format!("Create Worktree: \"{name}\"…")).color(
+ if is_disabled {
+ Color::Disabled
+ } else {
+ Color::Default
+ },
+ ));
+
+ Some(
+ if let Some(reason) = disabled_reason.clone() {
+ item.tooltip(Tooltip::text(reason))
+ } else {
+ item
+ }
+ .into_any_element(),
+ )
+ }
+ }
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option {
+ None
+ }
+
+ fn documentation_aside(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(self.selected_index)?;
+ let is_default = match entry {
+ ThreadWorktreeEntry::CurrentWorktree => {
+ let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
+ Some(new_thread_location == NewThreadLocation::LocalProject)
+ }
+ ThreadWorktreeEntry::NewWorktree => {
+ let project = self.project.read(cx);
+ let is_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
+ if is_disabled {
+ None
+ } else {
+ let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
+ Some(new_thread_location == NewThreadLocation::NewWorktree)
+ }
+ }
+ _ => None,
+ }?;
+
+ let side = crate::ui::documentation_aside_side(cx);
+
+ Some(DocumentationAside::new(
+ side,
+ Rc::new(move |_| {
+ HoldForDefault::new(is_default)
+ .more_content(false)
+ .into_any_element()
+ }),
+ ))
+ }
+
+ fn documentation_aside_index(&self) -> Option {
+ match self.matches.get(self.selected_index) {
+ Some(ThreadWorktreeEntry::CurrentWorktree | ThreadWorktreeEntry::NewWorktree) => {
+ Some(self.selected_index)
+ }
+ _ => None,
+ }
+ }
+}
diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs
index 13b2aa1a37cd506c338d13db78bce751882e426a..7cb8410e5017438b0e8adde673887c13397d9abf 100644
--- a/crates/agent_ui/src/threads_archive_view.rs
+++ b/crates/agent_ui/src/threads_archive_view.rs
@@ -1236,6 +1236,7 @@ impl PickerDelegate for ProjectPickerDelegate {
},
match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
paths: Vec::new(),
+ active: false,
};
Some(
diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs
index d43b7e4b043bcd1b155699c5eea3ca695585b94b..d2447e6e4508e7ee25d0a832c4580ad1c67e3bb8 100644
--- a/crates/agent_ui/src/ui.rs
+++ b/crates/agent_ui/src/ui.rs
@@ -13,3 +13,16 @@ pub use hold_for_default::*;
pub use mention_crease::*;
pub use model_selector_components::*;
pub use undo_reject_toast::*;
+
+/// Returns the appropriate [`DocumentationSide`] for documentation asides
+/// in the agent panel, based on the current dock position.
+pub fn documentation_aside_side(cx: &gpui::App) -> ui::DocumentationSide {
+ use agent_settings::AgentSettings;
+ use settings::Settings;
+ use ui::DocumentationSide;
+
+ match AgentSettings::get_global(cx).dock {
+ settings::DockPosition::Left => DocumentationSide::Right,
+ settings::DockPosition::Bottom | settings::DockPosition::Right => DocumentationSide::Left,
+ }
+}
diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_card.rs b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs
index c63c5926428ab47f80afd2e157f90f8852dbf4ee..d4070b701ecc1a648a9e48f3c61a3289f3c38bc9 100644
--- a/crates/ai_onboarding/src/agent_panel_onboarding_card.rs
+++ b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs
@@ -1,6 +1,6 @@
use gpui::{AnyElement, IntoElement, ParentElement, linear_color_stop, linear_gradient};
use smallvec::SmallVec;
-use ui::{Vector, VectorName, prelude::*};
+use ui::prelude::*;
#[derive(IntoElement)]
pub struct AgentPanelOnboardingCard {
@@ -23,61 +23,43 @@ impl ParentElement for AgentPanelOnboardingCard {
impl RenderOnce for AgentPanelOnboardingCard {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- div()
- .m_2p5()
- .p(px(3.))
- .elevation_2(cx)
- .rounded_lg()
- .bg(cx.theme().colors().background.alpha(0.5))
- .child(
- v_flex()
- .relative()
- .size_full()
- .px_4()
- .py_3()
- .gap_2()
- .border_1()
- .rounded(px(5.))
- .border_color(cx.theme().colors().text.alpha(0.1))
- .overflow_hidden()
- .bg(cx.theme().colors().panel_background)
- .child(
- div()
- .opacity(0.5)
- .absolute()
- .top(px(-8.0))
- .right_0()
- .w(px(400.))
- .h(px(92.))
- .rounded_md()
- .child(
- Vector::new(
- VectorName::AiGrid,
- rems_from_px(400.),
- rems_from_px(92.),
- )
- .color(Color::Custom(cx.theme().colors().text.alpha(0.32))),
- ),
- )
- .child(
- div()
- .absolute()
- .top_0p5()
- .right_0p5()
- .w(px(660.))
- .h(px(401.))
- .overflow_hidden()
- .rounded_md()
- .bg(linear_gradient(
- 75.,
- linear_color_stop(
- cx.theme().colors().panel_background.alpha(0.01),
- 1.0,
- ),
- linear_color_stop(cx.theme().colors().panel_background, 0.45),
- )),
- )
- .children(self.children),
- )
+ let color = cx.theme().colors();
+
+ div().min_w_0().p_2p5().bg(color.editor_background).child(
+ div()
+ .min_w_0()
+ .p(px(3.))
+ .rounded_lg()
+ .elevation_2(cx)
+ .bg(color.background.opacity(0.5))
+ .child(
+ v_flex()
+ .relative()
+ .size_full()
+ .min_w_0()
+ .px_4()
+ .py_3()
+ .gap_2()
+ .border_1()
+ .rounded(px(5.))
+ .border_color(color.text.opacity(0.1))
+ .bg(color.panel_background)
+ .overflow_hidden()
+ .child(
+ div()
+ .absolute()
+ .inset_0()
+ .size_full()
+ .rounded_md()
+ .overflow_hidden()
+ .bg(linear_gradient(
+ 360.,
+ linear_color_stop(color.panel_background, 1.0),
+ linear_color_stop(color.editor_background, 0.45),
+ )),
+ )
+ .children(self.children),
+ ),
+ )
}
}
diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs
index cc60a35e501329b0ca089e2f218ab1551ca35d93..494177da3c5f1fecee1784dfac9ce88026544f5a 100644
--- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs
+++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs
@@ -59,25 +59,26 @@ impl Render for AgentPanelOnboarding {
.read(cx)
.plan()
.is_some_and(|plan| plan == Plan::ZedProTrial);
+
let is_pro_user = self
.user_store
.read(cx)
.plan()
.is_some_and(|plan| plan == Plan::ZedPro);
+ let onboarding = ZedAiOnboarding::new(
+ self.client.clone(),
+ &self.user_store,
+ self.continue_with_zed_ai.clone(),
+ cx,
+ )
+ .with_dismiss({
+ let callback = self.continue_with_zed_ai.clone();
+ move |window, cx| callback(window, cx)
+ });
+
AgentPanelOnboardingCard::new()
- .child(
- ZedAiOnboarding::new(
- self.client.clone(),
- &self.user_store,
- self.continue_with_zed_ai.clone(),
- cx,
- )
- .with_dismiss({
- let callback = self.continue_with_zed_ai.clone();
- move |window, cx| callback(window, cx)
- }),
- )
+ .child(onboarding)
.map(|this| {
if enrolled_in_trial || is_pro_user || self.has_configured_providers {
this
diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs
index e05853fa167267c505d4424365c29844e0ce08db..f51389b8ca25b22798fc1ae9dc2ceee81d60110a 100644
--- a/crates/ai_onboarding/src/ai_onboarding.rs
+++ b/crates/ai_onboarding/src/ai_onboarding.rs
@@ -1,7 +1,6 @@
mod agent_api_keys_onboarding;
mod agent_panel_onboarding_card;
mod agent_panel_onboarding_content;
-mod ai_upsell_card;
mod edit_prediction_onboarding_content;
mod plan_definitions;
mod young_account_banner;
@@ -9,7 +8,6 @@ mod young_account_banner;
pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders};
pub use agent_panel_onboarding_card::AgentPanelOnboardingCard;
pub use agent_panel_onboarding_content::AgentPanelOnboarding;
-pub use ai_upsell_card::AiUpsellCard;
use cloud_api_types::Plan;
pub use edit_prediction_onboarding_content::EditPredictionOnboarding;
pub use plan_definitions::PlanDefinitions;
@@ -19,7 +17,9 @@ use std::sync::Arc;
use client::{Client, UserStore, zed_urls};
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
-use ui::{Divider, RegisterComponent, Tooltip, prelude::*};
+use ui::{
+ Divider, List, ListBulletItem, RegisterComponent, Tooltip, Vector, VectorName, prelude::*,
+};
#[derive(PartialEq)]
pub enum SignInStatus {
@@ -84,6 +84,50 @@ impl ZedAiOnboarding {
self
}
+ fn certified_user_stamp(cx: &App) -> impl IntoElement {
+ div().absolute().bottom_1().right_1().child(
+ Vector::new(
+ VectorName::ProUserStamp,
+ rems_from_px(156.),
+ rems_from_px(60.),
+ )
+ .color(Color::Custom(cx.theme().colors().text_accent.alpha(0.8))),
+ )
+ }
+
+ fn pro_trial_stamp(cx: &App) -> impl IntoElement {
+ div().absolute().bottom_1().right_1().child(
+ Vector::new(
+ VectorName::ProTrialStamp,
+ rems_from_px(156.),
+ rems_from_px(60.),
+ )
+ .color(Color::Custom(cx.theme().colors().text.alpha(0.8))),
+ )
+ }
+
+ fn business_stamp(cx: &App) -> impl IntoElement {
+ div().absolute().bottom_1().right_1().child(
+ Vector::new(
+ VectorName::BusinessStamp,
+ rems_from_px(156.),
+ rems_from_px(60.),
+ )
+ .color(Color::Custom(cx.theme().colors().text_accent.alpha(0.8))),
+ )
+ }
+
+ fn student_stamp(cx: &App) -> impl IntoElement {
+ div().absolute().bottom_1().right_1().child(
+ Vector::new(
+ VectorName::StudentStamp,
+ rems_from_px(156.),
+ rems_from_px(60.),
+ )
+ .color(Color::Custom(cx.theme().colors().text.alpha(0.8))),
+ )
+ }
+
fn render_dismiss_button(&self) -> Option {
self.dismiss_onboarding.as_ref().map(|dismiss_callback| {
let callback = dismiss_callback.clone();
@@ -109,6 +153,7 @@ impl ZedAiOnboarding {
let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
v_flex()
+ .w_full()
.relative()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
@@ -139,7 +184,7 @@ impl ZedAiOnboarding {
if self.account_too_young {
v_flex()
.relative()
- .max_w_full()
+ .min_w_0()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.child(YoungAccountBanner)
@@ -175,6 +220,7 @@ impl ZedAiOnboarding {
.into_any_element()
} else {
v_flex()
+ .w_full()
.relative()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
@@ -237,10 +283,12 @@ impl ZedAiOnboarding {
}
}
- fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
+ fn render_trial_state(&self, cx: &mut App) -> AnyElement {
v_flex()
+ .w_full()
.relative()
.gap_1()
+ .child(Self::pro_trial_stamp(cx))
.child(Headline::new("Welcome to the Zed Pro Trial"))
.child(
Label::new("Here's what you get for the next 14 days:")
@@ -252,9 +300,12 @@ impl ZedAiOnboarding {
.into_any_element()
}
- fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
+ fn render_pro_plan_state(&self, cx: &mut App) -> AnyElement {
v_flex()
+ .w_full()
+ .relative()
.gap_1()
+ .child(Self::certified_user_stamp(cx))
.child(Headline::new("Welcome to Zed Pro"))
.child(
Label::new("Here's what you get:")
@@ -266,9 +317,12 @@ impl ZedAiOnboarding {
.into_any_element()
}
- fn render_business_plan_state(&self, _cx: &mut App) -> AnyElement {
+ fn render_business_plan_state(&self, cx: &mut App) -> AnyElement {
v_flex()
+ .w_full()
+ .relative()
.gap_1()
+ .child(Self::business_stamp(cx))
.child(Headline::new("Welcome to Zed Business"))
.child(
Label::new("Here's what you get:")
@@ -280,9 +334,12 @@ impl ZedAiOnboarding {
.into_any_element()
}
- fn render_student_plan_state(&self, _cx: &mut App) -> AnyElement {
+ fn render_student_plan_state(&self, cx: &mut App) -> AnyElement {
v_flex()
+ .w_full()
+ .relative()
.gap_1()
+ .child(Self::student_stamp(cx))
.child(Headline::new("Welcome to Zed Student"))
.child(
Label::new("Here's what you get:")
@@ -318,11 +375,7 @@ impl Component for ZedAiOnboarding {
}
fn name() -> &'static str {
- "Agent Panel Banners"
- }
-
- fn sort_name() -> &'static str {
- "Agent Panel Banners"
+ "Agent New User Onboarding"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option {
@@ -331,22 +384,30 @@ impl Component for ZedAiOnboarding {
plan: Option,
account_too_young: bool,
) -> AnyElement {
- ZedAiOnboarding {
- sign_in_status,
- plan,
- account_too_young,
- continue_with_zed_ai: Arc::new(|_, _| {}),
- sign_in: Arc::new(|_, _| {}),
- dismiss_onboarding: None,
- }
- .into_any_element()
+ div()
+ .w_full()
+ .min_w_40()
+ .max_w(px(1100.))
+ .child(
+ AgentPanelOnboardingCard::new().child(
+ ZedAiOnboarding {
+ sign_in_status,
+ plan,
+ account_too_young,
+ continue_with_zed_ai: Arc::new(|_, _| {}),
+ sign_in: Arc::new(|_, _| {}),
+ dismiss_onboarding: None,
+ }
+ .into_any_element(),
+ ),
+ )
+ .into_any_element()
}
Some(
v_flex()
+ .min_w_0()
.gap_4()
- .items_center()
- .max_w_4_5()
.children(vec![
single_example(
"Not Signed-in",
@@ -381,3 +442,119 @@ impl Component for ZedAiOnboarding {
)
}
}
+
+#[derive(RegisterComponent)]
+pub struct AgentLayoutOnboarding {
+ pub use_agent_layout: Arc,
+ pub revert_to_editor_layout: Arc,
+ pub dismissed: Arc,
+ pub is_agent_layout: bool,
+}
+
+impl Render for AgentLayoutOnboarding {
+ fn render(&mut self, _window: &mut ui::Window, _cx: &mut Context) -> impl IntoElement {
+ let description = "The new threads sidebar, positioned in the far left of your workspace, allows you to manage agents across many projects. Your agent thread lives alongside it, and all other panels live on the right.";
+
+ let dismiss_button = div().absolute().top_1().right_1().child(
+ IconButton::new("dismiss", IconName::Close)
+ .icon_size(IconSize::Small)
+ .on_click({
+ let dismiss = self.dismissed.clone();
+ move |_, window, cx| {
+ telemetry::event!("Agentic Layout Onboarding Dismissed");
+ dismiss(window, cx)
+ }
+ }),
+ );
+
+ let primary_button = if self.is_agent_layout {
+ Button::new("revert", "Use Previous Layout")
+ .label_size(LabelSize::Small)
+ .style(ButtonStyle::Outlined)
+ .on_click({
+ let revert = self.revert_to_editor_layout.clone();
+ let dismiss = self.dismissed.clone();
+ move |_, window, cx| {
+ telemetry::event!("Clicked to Use Previous Layout");
+ revert(window, cx);
+ dismiss(window, cx);
+ }
+ })
+ } else {
+ Button::new("start", "Use New Layout")
+ .label_size(LabelSize::Small)
+ .style(ButtonStyle::Outlined)
+ .on_click({
+ let use_layout = self.use_agent_layout.clone();
+ let dismiss = self.dismissed.clone();
+ move |_, window, cx| {
+ telemetry::event!("Clicked to Use New Layout");
+ use_layout(window, cx);
+ dismiss(window, cx);
+ }
+ })
+ };
+
+ let content = v_flex()
+ .min_w_0()
+ .w_full()
+ .relative()
+ .gap_1()
+ .child(Label::new("A new workspace layout for agentic work"))
+ .child(Label::new(description).color(Color::Muted).mb_2())
+ .child(
+ List::new()
+ .child(ListBulletItem::new("Use your favorite agents in parallel"))
+ .child(ListBulletItem::new("Isolate agents using worktrees"))
+ .child(ListBulletItem::new(
+ "Combine multiple projects in one window",
+ )),
+ )
+ .child(
+ h_flex()
+ .w_full()
+ .gap_1()
+ .flex_wrap()
+ .justify_end()
+ .child(primary_button),
+ )
+ .child(dismiss_button);
+
+ AgentPanelOnboardingCard::new().child(content)
+ }
+}
+
+impl Component for AgentLayoutOnboarding {
+ fn scope() -> ComponentScope {
+ ComponentScope::Onboarding
+ }
+
+ fn name() -> &'static str {
+ "Agent Layout Onboarding"
+ }
+
+ fn preview(_window: &mut Window, cx: &mut App) -> Option {
+ let onboarding = cx.new(|_cx| AgentLayoutOnboarding {
+ use_agent_layout: Arc::new(|_, _| {}),
+ revert_to_editor_layout: Arc::new(|_, _| {}),
+ dismissed: Arc::new(|_, _| {}),
+ is_agent_layout: false,
+ });
+
+ Some(
+ v_flex()
+ .min_w_0()
+ .gap_4()
+ .child(single_example(
+ "Agent Layout Onboarding",
+ div()
+ .w_full()
+ .min_w_40()
+ .max_w(px(1100.))
+ .child(onboarding)
+ .into_any_element(),
+ ))
+ .into_any_element(),
+ )
+ }
+}
diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs
deleted file mode 100644
index cbaa9785db9e5471dd76a3add2cb9f19ca1b7ae1..0000000000000000000000000000000000000000
--- a/crates/ai_onboarding/src/ai_upsell_card.rs
+++ /dev/null
@@ -1,407 +0,0 @@
-use std::sync::Arc;
-
-use client::{Client, UserStore, zed_urls};
-use cloud_api_types::Plan;
-use gpui::{AnyElement, App, Entity, IntoElement, RenderOnce, Window};
-use ui::{CommonAnimationExt, Divider, Vector, VectorName, prelude::*};
-
-use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions};
-
-#[derive(IntoElement, RegisterComponent)]
-pub struct AiUpsellCard {
- sign_in_status: SignInStatus,
- sign_in: Arc,
- account_too_young: bool,
- user_plan: Option,
- tab_index: Option,
-}
-
-impl AiUpsellCard {
- pub fn new(
- client: Arc,
- user_store: &Entity,
- user_plan: Option,
- cx: &mut App,
- ) -> Self {
- let status = *client.status().borrow();
- let store = user_store.read(cx);
-
- Self {
- user_plan,
- sign_in_status: status.into(),
- sign_in: Arc::new(move |_window, cx| {
- cx.spawn({
- let client = client.clone();
- async move |cx| client.sign_in_with_optional_connect(true, cx).await
- })
- .detach_and_log_err(cx);
- }),
- account_too_young: store.account_too_young(),
- tab_index: None,
- }
- }
-
- pub fn tab_index(mut self, tab_index: Option) -> Self {
- self.tab_index = tab_index;
- self
- }
-}
-
-impl RenderOnce for AiUpsellCard {
- fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- let pro_section = v_flex()
- .flex_grow()
- .w_full()
- .gap_1()
- .child(
- h_flex()
- .gap_2()
- .child(
- Label::new("Pro")
- .size(LabelSize::Small)
- .color(Color::Accent)
- .buffer_font(cx),
- )
- .child(Divider::horizontal()),
- )
- .child(PlanDefinitions.pro_plan());
-
- let free_section = v_flex()
- .flex_grow()
- .w_full()
- .gap_1()
- .child(
- h_flex()
- .gap_2()
- .child(
- Label::new("Free")
- .size(LabelSize::Small)
- .color(Color::Muted)
- .buffer_font(cx),
- )
- .child(Divider::horizontal()),
- )
- .child(PlanDefinitions.free_plan());
-
- let grid_bg = h_flex()
- .absolute()
- .inset_0()
- .w_full()
- .h(px(240.))
- .bg(gpui::pattern_slash(
- cx.theme().colors().border.opacity(0.1),
- 2.,
- 25.,
- ));
-
- let gradient_bg = div()
- .absolute()
- .inset_0()
- .size_full()
- .bg(gpui::linear_gradient(
- 180.,
- gpui::linear_color_stop(
- cx.theme().colors().elevated_surface_background.opacity(0.8),
- 0.,
- ),
- gpui::linear_color_stop(
- cx.theme().colors().elevated_surface_background.opacity(0.),
- 0.8,
- ),
- ));
-
- let description = PlanDefinitions::AI_DESCRIPTION;
-
- let card = v_flex()
- .relative()
- .flex_grow()
- .p_4()
- .pt_3()
- .border_1()
- .border_color(cx.theme().colors().border)
- .rounded_lg()
- .overflow_hidden()
- .child(grid_bg)
- .child(gradient_bg);
-
- let plans_section = h_flex()
- .w_full()
- .mt_1p5()
- .mb_2p5()
- .items_start()
- .gap_6()
- .child(free_section)
- .child(pro_section);
-
- let footer_container = v_flex().items_center().gap_1();
-
- let certified_user_stamp = div()
- .absolute()
- .top_2()
- .right_2()
- .size(rems_from_px(72.))
- .child(
- Vector::new(
- VectorName::ProUserStamp,
- rems_from_px(72.),
- rems_from_px(72.),
- )
- .color(Color::Custom(cx.theme().colors().text_accent.alpha(0.3)))
- .with_rotate_animation(10),
- );
-
- let pro_trial_stamp = div()
- .absolute()
- .top_2()
- .right_2()
- .size(rems_from_px(72.))
- .child(
- Vector::new(
- VectorName::ProTrialStamp,
- rems_from_px(72.),
- rems_from_px(72.),
- )
- .color(Color::Custom(cx.theme().colors().text.alpha(0.2))),
- );
-
- match self.sign_in_status {
- SignInStatus::SignedIn => match self.user_plan {
- None | Some(Plan::ZedFree) => card
- .child(Label::new("Try Zed AI").size(LabelSize::Large))
- .map(|this| {
- if self.account_too_young {
- this.child(YoungAccountBanner).child(
- v_flex()
- .mt_2()
- .gap_1()
- .child(
- h_flex()
- .gap_2()
- .child(
- Label::new("Pro")
- .size(LabelSize::Small)
- .color(Color::Accent)
- .buffer_font(cx),
- )
- .child(Divider::horizontal()),
- )
- .child(PlanDefinitions.pro_plan())
- .child(
- Button::new("pro", "Get Started")
- .full_width()
- .style(ButtonStyle::Tinted(ui::TintColor::Accent))
- .on_click(move |_, _window, cx| {
- telemetry::event!(
- "Upgrade To Pro Clicked",
- state = "young-account"
- );
- cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
- }),
- ),
- )
- } else {
- this.child(
- div()
- .max_w_3_4()
- .mb_2()
- .child(Label::new(description).color(Color::Muted)),
- )
- .child(plans_section)
- .child(
- footer_container
- .child(
- Button::new("start_trial", "Start Pro Trial")
- .full_width()
- .style(ButtonStyle::Tinted(ui::TintColor::Accent))
- .when_some(self.tab_index, |this, tab_index| {
- this.tab_index(tab_index)
- })
- .on_click(move |_, _window, cx| {
- telemetry::event!(
- "Start Trial Clicked",
- state = "post-sign-in"
- );
- cx.open_url(&zed_urls::start_trial_url(cx))
- }),
- )
- .child(
- Label::new("14 days, no credit card required")
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- }
- }),
- Some(Plan::ZedProTrial) => card
- .child(pro_trial_stamp)
- .child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large))
- .child(
- Label::new("Here's what you get for the next 14 days:")
- .color(Color::Muted)
- .mb_2(),
- )
- .child(PlanDefinitions.pro_trial(false)),
- Some(Plan::ZedPro) => card
- .child(certified_user_stamp)
- .child(Label::new("You're in the Zed Pro plan").size(LabelSize::Large))
- .child(
- Label::new("Here's what you get:")
- .color(Color::Muted)
- .mb_2(),
- )
- .child(PlanDefinitions.pro_plan()),
- Some(Plan::ZedBusiness) => card
- .child(certified_user_stamp)
- .child(Label::new("You're in the Zed Business plan").size(LabelSize::Large))
- .child(
- Label::new("Here's what you get:")
- .color(Color::Muted)
- .mb_2(),
- )
- .child(PlanDefinitions.business_plan()),
- Some(Plan::ZedStudent) => card
- .child(certified_user_stamp)
- .child(Label::new("You're in the Zed Student plan").size(LabelSize::Large))
- .child(
- Label::new("Here's what you get:")
- .color(Color::Muted)
- .mb_2(),
- )
- .child(PlanDefinitions.student_plan()),
- },
- // Signed Out State
- _ => card
- .child(Label::new("Try Zed AI").size(LabelSize::Large))
- .child(
- div()
- .max_w_3_4()
- .mb_2()
- .child(Label::new(description).color(Color::Muted)),
- )
- .child(plans_section)
- .child(
- Button::new("sign_in", "Sign In")
- .full_width()
- .style(ButtonStyle::Tinted(ui::TintColor::Accent))
- .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index))
- .on_click({
- let callback = self.sign_in.clone();
- move |_, window, cx| {
- telemetry::event!("Start Trial Clicked", state = "pre-sign-in");
- callback(window, cx)
- }
- }),
- ),
- }
- }
-}
-
-impl Component for AiUpsellCard {
- fn scope() -> ComponentScope {
- ComponentScope::Onboarding
- }
-
- fn name() -> &'static str {
- "AI Upsell Card"
- }
-
- fn sort_name() -> &'static str {
- "AI Upsell Card"
- }
-
- fn description() -> Option<&'static str> {
- Some("A card presenting the Zed AI product during user's first-open onboarding flow.")
- }
-
- fn preview(_window: &mut Window, _cx: &mut App) -> Option {
- Some(
- v_flex()
- .gap_4()
- .items_center()
- .max_w_4_5()
- .child(single_example(
- "Signed Out State",
- AiUpsellCard {
- sign_in_status: SignInStatus::SignedOut,
- sign_in: Arc::new(|_, _| {}),
- account_too_young: false,
- user_plan: None,
- tab_index: Some(0),
- }
- .into_any_element(),
- ))
- .child(example_group_with_title(
- "Signed In States",
- vec![
- single_example(
- "Free Plan",
- AiUpsellCard {
- sign_in_status: SignInStatus::SignedIn,
- sign_in: Arc::new(|_, _| {}),
- account_too_young: false,
- user_plan: Some(Plan::ZedFree),
- tab_index: Some(1),
- }
- .into_any_element(),
- ),
- single_example(
- "Free Plan but Young Account",
- AiUpsellCard {
- sign_in_status: SignInStatus::SignedIn,
- sign_in: Arc::new(|_, _| {}),
- account_too_young: true,
- user_plan: Some(Plan::ZedFree),
- tab_index: Some(1),
- }
- .into_any_element(),
- ),
- single_example(
- "Pro Trial",
- AiUpsellCard {
- sign_in_status: SignInStatus::SignedIn,
- sign_in: Arc::new(|_, _| {}),
- account_too_young: false,
- user_plan: Some(Plan::ZedProTrial),
- tab_index: Some(1),
- }
- .into_any_element(),
- ),
- single_example(
- "Pro Plan",
- AiUpsellCard {
- sign_in_status: SignInStatus::SignedIn,
- sign_in: Arc::new(|_, _| {}),
- account_too_young: false,
- user_plan: Some(Plan::ZedPro),
- tab_index: Some(1),
- }
- .into_any_element(),
- ),
- single_example(
- "Business Plan",
- AiUpsellCard {
- sign_in_status: SignInStatus::SignedIn,
- sign_in: Arc::new(|_, _| {}),
- account_too_young: false,
- user_plan: Some(Plan::ZedBusiness),
- tab_index: Some(1),
- }
- .into_any_element(),
- ),
- single_example(
- "Student Plan",
- AiUpsellCard {
- sign_in_status: SignInStatus::SignedIn,
- sign_in: Arc::new(|_, _| {}),
- account_too_young: false,
- user_plan: Some(Plan::ZedStudent),
- tab_index: Some(1),
- }
- .into_any_element(),
- ),
- ],
- ))
- .into_any_element(),
- )
- }
-}
diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs
index 184815bcad9babb1892335c6207a79e1fe193c04..cc80b5ccf6d3d6ad06e7b3cf693356dbad3ce541 100644
--- a/crates/ai_onboarding/src/plan_definitions.rs
+++ b/crates/ai_onboarding/src/plan_definitions.rs
@@ -5,23 +5,19 @@ use ui::{List, ListBulletItem, prelude::*};
pub struct PlanDefinitions;
impl PlanDefinitions {
- pub const AI_DESCRIPTION: &'static str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI.";
-
pub fn free_plan(&self) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("2,000 accepted edit predictions"))
.child(ListBulletItem::new(
"Unlimited prompts with your AI API keys",
))
- .child(ListBulletItem::new(
- "Unlimited use of external agents like Claude Agent",
- ))
+ .child(ListBulletItem::new("Unlimited use of external agents"))
}
pub fn pro_trial(&self, period: bool) -> impl IntoElement {
List::new()
+ .child(ListBulletItem::new("$20 of tokens in Zed agent"))
.child(ListBulletItem::new("Unlimited edit predictions"))
- .child(ListBulletItem::new("$20 of tokens"))
.when(period, |this| {
this.child(ListBulletItem::new(
"Try it out for 14 days, no credit card required",
@@ -31,9 +27,9 @@ impl PlanDefinitions {
pub fn pro_plan(&self) -> impl IntoElement {
List::new()
- .child(ListBulletItem::new("Unlimited edit predictions"))
- .child(ListBulletItem::new("$5 of tokens"))
+ .child(ListBulletItem::new("$5 of tokens in Zed agent"))
.child(ListBulletItem::new("Usage-based billing beyond $5"))
+ .child(ListBulletItem::new("Unlimited edit predictions"))
}
pub fn business_plan(&self) -> impl IntoElement {
@@ -45,7 +41,7 @@ impl PlanDefinitions {
pub fn student_plan(&self) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("Unlimited edit predictions"))
- .child(ListBulletItem::new("$10 of tokens"))
+ .child(ListBulletItem::new("$10 of tokens in Zed agent"))
.child(ListBulletItem::new(
"Optional credit packs for additional usage",
))
diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml
index 1e2587435489dea6952c697b0e0a4cf627226728..458f9bfae7da4736c4e54e42f08b5e3a926ed30a 100644
--- a/crates/anthropic/Cargo.toml
+++ b/crates/anthropic/Cargo.toml
@@ -18,12 +18,16 @@ path = "src/anthropic.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
+collections.workspace = true
futures.workspace = true
http_client.workspace = true
+language_model_core.workspace = true
+log.workspace = true
schemars = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
thiserror.workspace = true
+tiktoken-rs.workspace = true
diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs
index 5d7790b86b09853e22436252fcde1bebf5feff9b..48fa318d7c1d87e63725cef836baf9c945966206 100644
--- a/crates/anthropic/src/anthropic.rs
+++ b/crates/anthropic/src/anthropic.rs
@@ -12,6 +12,7 @@ use strum::{EnumIter, EnumString};
use thiserror::Error;
pub mod batches;
+pub mod completion;
pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com";
@@ -1026,6 +1027,89 @@ pub async fn count_tokens(
}
}
+// -- Conversions from/to `language_model_core` types --
+
+impl From for Speed {
+ fn from(speed: language_model_core::Speed) -> Self {
+ match speed {
+ language_model_core::Speed::Standard => Speed::Standard,
+ language_model_core::Speed::Fast => Speed::Fast,
+ }
+ }
+}
+
+impl From for language_model_core::LanguageModelCompletionError {
+ fn from(error: AnthropicError) -> Self {
+ let provider = language_model_core::ANTHROPIC_PROVIDER_NAME;
+ match error {
+ AnthropicError::SerializeRequest(error) => Self::SerializeRequest { provider, error },
+ AnthropicError::BuildRequestBody(error) => Self::BuildRequestBody { provider, error },
+ AnthropicError::HttpSend(error) => Self::HttpSend { provider, error },
+ AnthropicError::DeserializeResponse(error) => {
+ Self::DeserializeResponse { provider, error }
+ }
+ AnthropicError::ReadResponse(error) => Self::ApiReadResponseError { provider, error },
+ AnthropicError::HttpResponseError {
+ status_code,
+ message,
+ } => Self::HttpResponseError {
+ provider,
+ status_code,
+ message,
+ },
+ AnthropicError::RateLimit { retry_after } => Self::RateLimitExceeded {
+ provider,
+ retry_after: Some(retry_after),
+ },
+ AnthropicError::ServerOverloaded { retry_after } => Self::ServerOverloaded {
+ provider,
+ retry_after,
+ },
+ AnthropicError::ApiError(api_error) => api_error.into(),
+ }
+ }
+}
+
+impl From for language_model_core::LanguageModelCompletionError {
+ fn from(error: ApiError) -> Self {
+ use ApiErrorCode::*;
+ let provider = language_model_core::ANTHROPIC_PROVIDER_NAME;
+ match error.code() {
+ Some(code) => match code {
+ InvalidRequestError => Self::BadRequestFormat {
+ provider,
+ message: error.message,
+ },
+ AuthenticationError => Self::AuthenticationError {
+ provider,
+ message: error.message,
+ },
+ PermissionError => Self::PermissionError {
+ provider,
+ message: error.message,
+ },
+ NotFoundError => Self::ApiEndpointNotFound { provider },
+ RequestTooLarge => Self::PromptTooLarge {
+ tokens: language_model_core::parse_prompt_too_long(&error.message),
+ },
+ RateLimitError => Self::RateLimitExceeded {
+ provider,
+ retry_after: None,
+ },
+ ApiError => Self::ApiInternalServerError {
+ provider,
+ message: error.message,
+ },
+ OverloadedError => Self::ServerOverloaded {
+ provider,
+ retry_after: None,
+ },
+ },
+ None => Self::Other(error.into()),
+ }
+ }
+}
+
#[test]
fn test_match_window_exceeded() {
let error = ApiError {
diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs
new file mode 100644
index 0000000000000000000000000000000000000000..a6175a4f7c24b3b724734b2edef48ef8acfaa159
--- /dev/null
+++ b/crates/anthropic/src/completion.rs
@@ -0,0 +1,765 @@
+use anyhow::Result;
+use collections::HashMap;
+use futures::{Stream, StreamExt};
+use language_model_core::{
+ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest,
+ LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+ Role, StopReason, TokenUsage,
+ util::{fix_streamed_json, parse_tool_arguments},
+};
+use std::pin::Pin;
+use std::str::FromStr;
+
+use crate::{
+ AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, ContentDelta,
+ CountTokensRequest, Event, ImageSource, Message, RequestContent, ResponseContent,
+ StringOrContents, Thinking, Tool, ToolChoice, ToolResultContent, ToolResultPart, Usage,
+};
+
+fn to_anthropic_content(content: MessageContent) -> Option {
+ match content {
+ MessageContent::Text(text) => {
+ let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
+ text.trim_end().to_string()
+ } else {
+ text
+ };
+ if !text.is_empty() {
+ Some(RequestContent::Text {
+ text,
+ cache_control: None,
+ })
+ } else {
+ None
+ }
+ }
+ MessageContent::Thinking {
+ text: thinking,
+ signature,
+ } => {
+ if let Some(signature) = signature
+ && !thinking.is_empty()
+ {
+ Some(RequestContent::Thinking {
+ thinking,
+ signature,
+ cache_control: None,
+ })
+ } else {
+ None
+ }
+ }
+ MessageContent::RedactedThinking(data) => {
+ if !data.is_empty() {
+ Some(RequestContent::RedactedThinking { data })
+ } else {
+ None
+ }
+ }
+ MessageContent::Image(image) => Some(RequestContent::Image {
+ source: ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ cache_control: None,
+ }),
+ MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse {
+ id: tool_use.id.to_string(),
+ name: tool_use.name.to_string(),
+ input: tool_use.input,
+ cache_control: None,
+ }),
+ MessageContent::ToolResult(tool_result) => Some(RequestContent::ToolResult {
+ tool_use_id: tool_result.tool_use_id.to_string(),
+ is_error: tool_result.is_error,
+ content: match tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ ToolResultContent::Plain(text.to_string())
+ }
+ LanguageModelToolResultContent::Image(image) => {
+ ToolResultContent::Multipart(vec![ToolResultPart::Image {
+ source: ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ }])
+ }
+ },
+ cache_control: None,
+ }),
+ }
+}
+
+/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest.
+pub fn into_anthropic_count_tokens_request(
+ request: LanguageModelRequest,
+ model: String,
+ mode: AnthropicModelMode,
+) -> CountTokensRequest {
+ let mut new_messages: Vec = Vec::new();
+ let mut system_message = String::new();
+
+ for message in request.messages {
+ if message.contents_empty() {
+ continue;
+ }
+
+ match message.role {
+ Role::User | Role::Assistant => {
+ let anthropic_message_content: Vec = message
+ .content
+ .into_iter()
+ .filter_map(to_anthropic_content)
+ .collect();
+ let anthropic_role = match message.role {
+ Role::User => crate::Role::User,
+ Role::Assistant => crate::Role::Assistant,
+ Role::System => unreachable!("System role should never occur here"),
+ };
+ if anthropic_message_content.is_empty() {
+ continue;
+ }
+
+ if let Some(last_message) = new_messages.last_mut()
+ && last_message.role == anthropic_role
+ {
+ last_message.content.extend(anthropic_message_content);
+ continue;
+ }
+
+ new_messages.push(Message {
+ role: anthropic_role,
+ content: anthropic_message_content,
+ });
+ }
+ Role::System => {
+ if !system_message.is_empty() {
+ system_message.push_str("\n\n");
+ }
+ system_message.push_str(&message.string_contents());
+ }
+ }
+ }
+
+ CountTokensRequest {
+ model,
+ messages: new_messages,
+ system: if system_message.is_empty() {
+ None
+ } else {
+ Some(StringOrContents::String(system_message))
+ },
+ thinking: if request.thinking_allowed {
+ match mode {
+ AnthropicModelMode::Thinking { budget_tokens } => {
+ Some(Thinking::Enabled { budget_tokens })
+ }
+ AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive),
+ AnthropicModelMode::Default => None,
+ }
+ } else {
+ None
+ },
+ tools: request
+ .tools
+ .into_iter()
+ .map(|tool| Tool {
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.input_schema,
+ eager_input_streaming: tool.use_input_streaming,
+ })
+ .collect(),
+ tool_choice: request.tool_choice.map(|choice| match choice {
+ LanguageModelToolChoice::Auto => ToolChoice::Auto,
+ LanguageModelToolChoice::Any => ToolChoice::Any,
+ LanguageModelToolChoice::None => ToolChoice::None,
+ }),
+ }
+}
+
+/// Estimate tokens using tiktoken. Used as a fallback when the API is unavailable,
+/// or by providers (like Zed Cloud) that don't have direct Anthropic API access.
+pub fn count_anthropic_tokens_with_tiktoken(request: LanguageModelRequest) -> Result {
+ let messages = request.messages;
+ let mut tokens_from_images = 0;
+ let mut string_messages = Vec::with_capacity(messages.len());
+
+ for message in messages {
+ let mut string_contents = String::new();
+
+ for content in message.content {
+ match content {
+ MessageContent::Text(text) => {
+ string_contents.push_str(&text);
+ }
+ MessageContent::Thinking { .. } => {
+ // Thinking blocks are not included in the input token count.
+ }
+ MessageContent::RedactedThinking(_) => {
+ // Thinking blocks are not included in the input token count.
+ }
+ MessageContent::Image(image) => {
+ tokens_from_images += image.estimate_tokens();
+ }
+ MessageContent::ToolUse(_tool_use) => {
+ // TODO: Estimate token usage from tool uses.
+ }
+ MessageContent::ToolResult(tool_result) => match &tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ string_contents.push_str(text);
+ }
+ LanguageModelToolResultContent::Image(image) => {
+ tokens_from_images += image.estimate_tokens();
+ }
+ },
+ }
+ }
+
+ if !string_contents.is_empty() {
+ string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
+ role: match message.role {
+ Role::User => "user".into(),
+ Role::Assistant => "assistant".into(),
+ Role::System => "system".into(),
+ },
+ content: Some(string_contents),
+ name: None,
+ function_call: None,
+ });
+ }
+ }
+
+ // Tiktoken doesn't yet support these models, so we manually use the
+ // same tokenizer as GPT-4.
+ tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
+ .map(|tokens| (tokens + tokens_from_images) as u64)
+}
+
+pub fn into_anthropic(
+ request: LanguageModelRequest,
+ model: String,
+ default_temperature: f32,
+ max_output_tokens: u64,
+ mode: AnthropicModelMode,
+) -> crate::Request {
+ let mut new_messages: Vec = Vec::new();
+ let mut system_message = String::new();
+
+ for message in request.messages {
+ if message.contents_empty() {
+ continue;
+ }
+
+ match message.role {
+ Role::User | Role::Assistant => {
+ let mut anthropic_message_content: Vec = message
+ .content
+ .into_iter()
+ .filter_map(to_anthropic_content)
+ .collect();
+ let anthropic_role = match message.role {
+ Role::User => crate::Role::User,
+ Role::Assistant => crate::Role::Assistant,
+ Role::System => unreachable!("System role should never occur here"),
+ };
+ if anthropic_message_content.is_empty() {
+ continue;
+ }
+
+ if let Some(last_message) = new_messages.last_mut()
+ && last_message.role == anthropic_role
+ {
+ last_message.content.extend(anthropic_message_content);
+ continue;
+ }
+
+ // Mark the last segment of the message as cached
+ if message.cache {
+ let cache_control_value = Some(CacheControl {
+ cache_type: CacheControlType::Ephemeral,
+ });
+ for message_content in anthropic_message_content.iter_mut().rev() {
+ match message_content {
+ RequestContent::RedactedThinking { .. } => {
+ // Caching is not possible, fallback to next message
+ }
+ RequestContent::Text { cache_control, .. }
+ | RequestContent::Thinking { cache_control, .. }
+ | RequestContent::Image { cache_control, .. }
+ | RequestContent::ToolUse { cache_control, .. }
+ | RequestContent::ToolResult { cache_control, .. } => {
+ *cache_control = cache_control_value;
+ break;
+ }
+ }
+ }
+ }
+
+ new_messages.push(Message {
+ role: anthropic_role,
+ content: anthropic_message_content,
+ });
+ }
+ Role::System => {
+ if !system_message.is_empty() {
+ system_message.push_str("\n\n");
+ }
+ system_message.push_str(&message.string_contents());
+ }
+ }
+ }
+
+ crate::Request {
+ model,
+ messages: new_messages,
+ max_tokens: max_output_tokens,
+ system: if system_message.is_empty() {
+ None
+ } else {
+ Some(StringOrContents::String(system_message))
+ },
+ thinking: if request.thinking_allowed {
+ match mode {
+ AnthropicModelMode::Thinking { budget_tokens } => {
+ Some(Thinking::Enabled { budget_tokens })
+ }
+ AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive),
+ AnthropicModelMode::Default => None,
+ }
+ } else {
+ None
+ },
+ tools: request
+ .tools
+ .into_iter()
+ .map(|tool| Tool {
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.input_schema,
+ eager_input_streaming: tool.use_input_streaming,
+ })
+ .collect(),
+ tool_choice: request.tool_choice.map(|choice| match choice {
+ LanguageModelToolChoice::Auto => ToolChoice::Auto,
+ LanguageModelToolChoice::Any => ToolChoice::Any,
+ LanguageModelToolChoice::None => ToolChoice::None,
+ }),
+ metadata: None,
+ output_config: if request.thinking_allowed
+ && matches!(mode, AnthropicModelMode::AdaptiveThinking)
+ {
+ request.thinking_effort.as_deref().and_then(|effort| {
+ let effort = match effort {
+ "low" => Some(crate::Effort::Low),
+ "medium" => Some(crate::Effort::Medium),
+ "high" => Some(crate::Effort::High),
+ "max" => Some(crate::Effort::Max),
+ _ => None,
+ };
+ effort.map(|effort| crate::OutputConfig {
+ effort: Some(effort),
+ })
+ })
+ } else {
+ None
+ },
+ stop_sequences: Vec::new(),
+ speed: request.speed.map(Into::into),
+ temperature: request.temperature.or(Some(default_temperature)),
+ top_k: None,
+ top_p: None,
+ }
+}
+
+pub struct AnthropicEventMapper {
+ tool_uses_by_index: HashMap,
+ usage: Usage,
+ stop_reason: StopReason,
+}
+
+impl AnthropicEventMapper {
+ pub fn new() -> Self {
+ Self {
+ tool_uses_by_index: HashMap::default(),
+ usage: Usage::default(),
+ stop_reason: StopReason::EndTurn,
+ }
+ }
+
+ pub fn map_stream(
+ mut self,
+ events: Pin>>>,
+ ) -> impl Stream- >
+ {
+ events.flat_map(move |event| {
+ futures::stream::iter(match event {
+ Ok(event) => self.map_event(event),
+ Err(error) => vec![Err(error.into())],
+ })
+ })
+ }
+
+ pub fn map_event(
+ &mut self,
+ event: Event,
+ ) -> Vec> {
+ match event {
+ Event::ContentBlockStart {
+ index,
+ content_block,
+ } => match content_block {
+ ResponseContent::Text { text } => {
+ vec![Ok(LanguageModelCompletionEvent::Text(text))]
+ }
+ ResponseContent::Thinking { thinking } => {
+ vec![Ok(LanguageModelCompletionEvent::Thinking {
+ text: thinking,
+ signature: None,
+ })]
+ }
+ ResponseContent::RedactedThinking { data } => {
+ vec![Ok(LanguageModelCompletionEvent::RedactedThinking { data })]
+ }
+ ResponseContent::ToolUse { id, name, .. } => {
+ self.tool_uses_by_index.insert(
+ index,
+ RawToolUse {
+ id,
+ name,
+ input_json: String::new(),
+ },
+ );
+ Vec::new()
+ }
+ },
+ Event::ContentBlockDelta { index, delta } => match delta {
+ ContentDelta::TextDelta { text } => {
+ vec![Ok(LanguageModelCompletionEvent::Text(text))]
+ }
+ ContentDelta::ThinkingDelta { thinking } => {
+ vec![Ok(LanguageModelCompletionEvent::Thinking {
+ text: thinking,
+ signature: None,
+ })]
+ }
+ ContentDelta::SignatureDelta { signature } => {
+ vec![Ok(LanguageModelCompletionEvent::Thinking {
+ text: "".to_string(),
+ signature: Some(signature),
+ })]
+ }
+ ContentDelta::InputJsonDelta { partial_json } => {
+ if let Some(tool_use) = self.tool_uses_by_index.get_mut(&index) {
+ tool_use.input_json.push_str(&partial_json);
+
+ // Try to convert invalid (incomplete) JSON into
+ // valid JSON that serde can accept, e.g. by closing
+ // unclosed delimiters. This way, we can update the
+ // UI with whatever has been streamed back so far.
+ if let Ok(input) =
+ serde_json::Value::from_str(&fix_streamed_json(&tool_use.input_json))
+ {
+ return vec![Ok(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: tool_use.id.clone().into(),
+ name: tool_use.name.clone().into(),
+ is_input_complete: false,
+ raw_input: tool_use.input_json.clone(),
+ input,
+ thought_signature: None,
+ },
+ ))];
+ }
+ }
+ vec![]
+ }
+ },
+ Event::ContentBlockStop { index } => {
+ if let Some(tool_use) = self.tool_uses_by_index.remove(&index) {
+ let input_json = tool_use.input_json.trim();
+ let event_result = match parse_tool_arguments(input_json) {
+ Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: tool_use.id.into(),
+ name: tool_use.name.into(),
+ is_input_complete: true,
+ input,
+ raw_input: tool_use.input_json.clone(),
+ thought_signature: None,
+ },
+ )),
+ Err(json_parse_err) => {
+ Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
+ id: tool_use.id.into(),
+ tool_name: tool_use.name.into(),
+ raw_input: input_json.into(),
+ json_parse_error: json_parse_err.to_string(),
+ })
+ }
+ };
+
+ vec![event_result]
+ } else {
+ Vec::new()
+ }
+ }
+ Event::MessageStart { message } => {
+ update_usage(&mut self.usage, &message.usage);
+ vec![
+ Ok(LanguageModelCompletionEvent::UsageUpdate(convert_usage(
+ &self.usage,
+ ))),
+ Ok(LanguageModelCompletionEvent::StartMessage {
+ message_id: message.id,
+ }),
+ ]
+ }
+ Event::MessageDelta { delta, usage } => {
+ update_usage(&mut self.usage, &usage);
+ if let Some(stop_reason) = delta.stop_reason.as_deref() {
+ self.stop_reason = match stop_reason {
+ "end_turn" => StopReason::EndTurn,
+ "max_tokens" => StopReason::MaxTokens,
+ "tool_use" => StopReason::ToolUse,
+ "refusal" => StopReason::Refusal,
+ _ => {
+ log::error!("Unexpected anthropic stop_reason: {stop_reason}");
+ StopReason::EndTurn
+ }
+ };
+ }
+ vec![Ok(LanguageModelCompletionEvent::UsageUpdate(
+ convert_usage(&self.usage),
+ ))]
+ }
+ Event::MessageStop => {
+ vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))]
+ }
+ Event::Error { error } => {
+ vec![Err(error.into())]
+ }
+ _ => Vec::new(),
+ }
+ }
+}
+
+struct RawToolUse {
+ id: String,
+ name: String,
+ input_json: String,
+}
+
+/// Updates usage data by preferring counts from `new`.
+fn update_usage(usage: &mut Usage, new: &Usage) {
+ if let Some(input_tokens) = new.input_tokens {
+ usage.input_tokens = Some(input_tokens);
+ }
+ if let Some(output_tokens) = new.output_tokens {
+ usage.output_tokens = Some(output_tokens);
+ }
+ if let Some(cache_creation_input_tokens) = new.cache_creation_input_tokens {
+ usage.cache_creation_input_tokens = Some(cache_creation_input_tokens);
+ }
+ if let Some(cache_read_input_tokens) = new.cache_read_input_tokens {
+ usage.cache_read_input_tokens = Some(cache_read_input_tokens);
+ }
+}
+
+fn convert_usage(usage: &Usage) -> TokenUsage {
+ TokenUsage {
+ input_tokens: usage.input_tokens.unwrap_or(0),
+ output_tokens: usage.output_tokens.unwrap_or(0),
+ cache_creation_input_tokens: usage.cache_creation_input_tokens.unwrap_or(0),
+ cache_read_input_tokens: usage.cache_read_input_tokens.unwrap_or(0),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::AnthropicModelMode;
+ use language_model_core::{LanguageModelImage, LanguageModelRequestMessage, MessageContent};
+
+ #[test]
+ fn test_cache_control_only_on_last_segment() {
+ let request = LanguageModelRequest {
+ messages: vec![LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![
+ MessageContent::Text("Some prompt".to_string()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ ],
+ cache: true,
+ reasoning_details: None,
+ }],
+ thread_id: None,
+ prompt_id: None,
+ intent: None,
+ stop: vec![],
+ temperature: None,
+ tools: vec![],
+ tool_choice: None,
+ thinking_allowed: true,
+ thinking_effort: None,
+ speed: None,
+ };
+
+ let anthropic_request = into_anthropic(
+ request,
+ "claude-3-5-sonnet".to_string(),
+ 0.7,
+ 4096,
+ AnthropicModelMode::Default,
+ );
+
+ assert_eq!(anthropic_request.messages.len(), 1);
+
+ let message = &anthropic_request.messages[0];
+ assert_eq!(message.content.len(), 5);
+
+ assert!(matches!(
+ message.content[0],
+ RequestContent::Text {
+ cache_control: None,
+ ..
+ }
+ ));
+ for i in 1..3 {
+ assert!(matches!(
+ message.content[i],
+ RequestContent::Image {
+ cache_control: None,
+ ..
+ }
+ ));
+ }
+
+ assert!(matches!(
+ message.content[4],
+ RequestContent::Image {
+ cache_control: Some(CacheControl {
+ cache_type: CacheControlType::Ephemeral,
+ }),
+ ..
+ }
+ ));
+ }
+
+ fn request_with_assistant_content(assistant_content: Vec) -> crate::Request {
+ let mut request = LanguageModelRequest {
+ messages: vec![LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::Text("Hello".to_string())],
+ cache: false,
+ reasoning_details: None,
+ }],
+ thinking_effort: None,
+ thread_id: None,
+ prompt_id: None,
+ intent: None,
+ stop: vec![],
+ temperature: None,
+ tools: vec![],
+ tool_choice: None,
+ thinking_allowed: true,
+ speed: None,
+ };
+ request.messages.push(LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: assistant_content,
+ cache: false,
+ reasoning_details: None,
+ });
+ into_anthropic(
+ request,
+ "claude-sonnet-4-5".to_string(),
+ 1.0,
+ 16000,
+ AnthropicModelMode::Thinking {
+ budget_tokens: Some(10000),
+ },
+ )
+ }
+
+ #[test]
+ fn test_unsigned_thinking_blocks_stripped() {
+ let result = request_with_assistant_content(vec![
+ MessageContent::Thinking {
+ text: "Cancelled mid-think, no signature".to_string(),
+ signature: None,
+ },
+ MessageContent::Text("Some response text".to_string()),
+ ]);
+
+ let assistant_message = result
+ .messages
+ .iter()
+ .find(|m| m.role == crate::Role::Assistant)
+ .expect("assistant message should still exist");
+
+ assert_eq!(
+ assistant_message.content.len(),
+ 1,
+ "Only the text content should remain; unsigned thinking block should be stripped"
+ );
+ assert!(matches!(
+ &assistant_message.content[0],
+ RequestContent::Text { text, .. } if text == "Some response text"
+ ));
+ }
+
+ #[test]
+ fn test_signed_thinking_blocks_preserved() {
+ let result = request_with_assistant_content(vec![
+ MessageContent::Thinking {
+ text: "Completed thinking".to_string(),
+ signature: Some("valid-signature".to_string()),
+ },
+ MessageContent::Text("Response".to_string()),
+ ]);
+
+ let assistant_message = result
+ .messages
+ .iter()
+ .find(|m| m.role == crate::Role::Assistant)
+ .expect("assistant message should exist");
+
+ assert_eq!(
+ assistant_message.content.len(),
+ 2,
+ "Both the signed thinking block and text should be preserved"
+ );
+ assert!(matches!(
+ &assistant_message.content[0],
+ RequestContent::Thinking { thinking, signature, .. }
+ if thinking == "Completed thinking" && signature == "valid-signature"
+ ));
+ }
+
+ #[test]
+ fn test_only_unsigned_thinking_block_omits_entire_message() {
+ let result = request_with_assistant_content(vec![MessageContent::Thinking {
+ text: "Cancelled before any text or signature".to_string(),
+ signature: None,
+ }]);
+
+ let assistant_messages: Vec<_> = result
+ .messages
+ .iter()
+ .filter(|m| m.role == crate::Role::Assistant)
+ .collect();
+
+ assert_eq!(
+ assistant_messages.len(),
+ 0,
+ "An assistant message whose only content was an unsigned thinking block \
+ should be omitted entirely"
+ );
+ }
+}
diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml
index 76d2c7210f68711646758e809825bca73dccdc1d..b7b51c4a28448434ec4483f898e2d67b3301533e 100644
--- a/crates/auto_update_ui/Cargo.toml
+++ b/crates/auto_update_ui/Cargo.toml
@@ -12,9 +12,12 @@ workspace = true
path = "src/auto_update_ui.rs"
[dependencies]
+agent_settings.workspace = true
anyhow.workspace = true
auto_update.workspace = true
client.workspace = true
+db.workspace = true
+fs.workspace = true
editor.workspace = true
gpui.workspace = true
markdown_preview.workspace = true
@@ -23,6 +26,8 @@ semver.workspace = true
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
+telemetry.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
+zed_actions.workspace = true
diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs
index e613d3af68875267f6a678505b83d605b9f8425c..4ff5df72bab2539218f546444be015d63fa97712 100644
--- a/crates/auto_update_ui/src/auto_update_ui.rs
+++ b/crates/auto_update_ui/src/auto_update_ui.rs
@@ -1,5 +1,10 @@
+use std::sync::Arc;
+
+use agent_settings::{AgentSettings, WindowLayout};
use auto_update::{AutoUpdater, release_notes_url};
+use db::kvp::Dismissable;
use editor::{Editor, MultiBuffer};
+use fs::Fs;
use gpui::{
App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*,
};
@@ -8,10 +13,10 @@ use release_channel::{AppVersion, ReleaseChannel};
use semver::Version;
use serde::Deserialize;
use smol::io::AsyncReadExt;
-use ui::{AnnouncementToast, ListBulletItem, prelude::*};
+use ui::{AnnouncementToast, ListBulletItem, ParallelAgentsIllustration, prelude::*};
use util::{ResultExt as _, maybe};
use workspace::{
- Workspace,
+ ToggleWorkspaceSidebar, Workspace,
notifications::{
ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification,
simple_message_notification::MessageNotification,
@@ -169,23 +174,52 @@ struct AnnouncementContent {
bullet_items: Vec,
primary_action_label: SharedString,
primary_action_url: Option,
+ primary_action_callback: Option>,
+ secondary_action_url: Option,
+ on_dismiss: Option>,
+}
+
+struct ParallelAgentAnnouncement;
+
+impl Dismissable for ParallelAgentAnnouncement {
+ const KEY: &'static str = "parallel-agent-announcement";
}
-fn announcement_for_version(version: &Version) -> Option {
- #[allow(clippy::match_single_binding)]
+fn announcement_for_version(version: &Version, cx: &App) -> Option {
match (version.major, version.minor, version.patch) {
- // TODO: Add real version when we have it
- // (0, 225, 0) => Some(AnnouncementContent {
- // heading: "What's new in Zed 0.225".into(),
- // description: "This release includes some exciting improvements.".into(),
- // bullet_items: vec![
- // "Improved agent performance".into(),
- // "New agentic features".into(),
- // "Better agent capabilities".into(),
- // ],
- // primary_action_label: "Learn More".into(),
- // primary_action_url: Some("https://zed.dev/".into()),
- // }),
+ (0, 232, _) => {
+ if ParallelAgentAnnouncement::dismissed(cx) {
+ None
+ } else {
+ let fs = ::global(cx);
+ let already_agent_layout =
+ matches!(AgentSettings::get_layout(cx), WindowLayout::Agent(_));
+
+ Some(AnnouncementContent {
+ heading: "Introducing Parallel Agents".into(),
+ description: "Run multiple agent threads simultaneously across projects."
+ .into(),
+ bullet_items: vec![
+ "Mix and match Zed's agent with any ACP-compatible agent".into(),
+ "Optional worktree isolation keeps agents from conflicting".into(),
+ "Updated workspace layout designed for agentic workflows".into(),
+ ],
+ primary_action_label: "Try Now".into(),
+ primary_action_url: None,
+ primary_action_callback: Some(Arc::new(move |window, cx| {
+ if !already_agent_layout {
+ AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
+ }
+ window.dispatch_action(Box::new(ToggleWorkspaceSidebar), cx);
+ window.dispatch_action(Box::new(zed_actions::assistant::ToggleFocus), cx);
+ })),
+ on_dismiss: Some(Arc::new(|cx| {
+ ParallelAgentAnnouncement::set_dismissed(true, cx)
+ })),
+ secondary_action_url: Some("https://zed.dev/blog/".into()),
+ })
+ }
+ }
_ => None,
}
}
@@ -202,6 +236,13 @@ impl AnnouncementToastNotification {
content,
}
}
+
+ fn dismiss(&mut self, cx: &mut Context) {
+ cx.emit(DismissEvent);
+ if let Some(on_dismiss) = &self.content.on_dismiss {
+ on_dismiss(cx);
+ }
+ }
}
impl Focusable for AnnouncementToastNotification {
@@ -217,6 +258,7 @@ impl Notification for AnnouncementToastNotification {}
impl Render for AnnouncementToastNotification {
fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
AnnouncementToast::new()
+ .illustration(ParallelAgentsIllustration::new())
.heading(self.content.heading.clone())
.description(self.content.description.clone())
.bullet_items(
@@ -228,24 +270,31 @@ impl Render for AnnouncementToastNotification {
.primary_action_label(self.content.primary_action_label.clone())
.primary_on_click(cx.listener({
let url = self.content.primary_action_url.clone();
- move |_, _, _window, cx| {
+ let callback = self.content.primary_action_callback.clone();
+ move |this, _, window, cx| {
+ telemetry::event!("Parallel Agent Announcement Main Click");
+ if let Some(callback) = &callback {
+ callback(window, cx);
+ }
if let Some(url) = &url {
cx.open_url(url);
}
- cx.emit(DismissEvent);
+ this.dismiss(cx);
}
}))
.secondary_on_click(cx.listener({
- let url = self.content.primary_action_url.clone();
- move |_, _, _window, cx| {
+ let url = self.content.secondary_action_url.clone();
+ move |this, _, _window, cx| {
+ telemetry::event!("Parallel Agent Announcement Secondary Click");
if let Some(url) = &url {
cx.open_url(url);
}
- cx.emit(DismissEvent);
+ this.dismiss(cx);
}
}))
- .dismiss_on_click(cx.listener(|_, _, _window, cx| {
- cx.emit(DismissEvent);
+ .dismiss_on_click(cx.listener(|this, _, _window, cx| {
+ telemetry::event!("Parallel Agent Announcement Dismiss");
+ this.dismiss(cx);
}))
}
}
@@ -274,7 +323,7 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
version.build = semver::BuildMetadata::EMPTY;
let app_name = ReleaseChannel::global(cx).display_name();
- if let Some(content) = announcement_for_version(&version) {
+ if let Some(content) = announcement_for_version(&version, cx) {
show_app_notification(
NotificationId::unique::(),
cx,
diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs
index 8b6113e4d5521fb3c7e27a7f2f6547c7a9db86ce..7c1e6e0e4e6ef873345c30c0af4c9e8842699c77 100644
--- a/crates/bedrock/src/models.rs
+++ b/crates/bedrock/src/models.rs
@@ -113,6 +113,10 @@ pub enum Model {
MistralLarge3,
#[serde(rename = "pixtral-large")]
PixtralLarge,
+ #[serde(rename = "devstral-2-123b")]
+ Devstral2_123B,
+ #[serde(rename = "ministral-14b")]
+ Ministral14B,
// Qwen models
#[serde(rename = "qwen3-32b")]
@@ -146,9 +150,27 @@ pub enum Model {
#[serde(rename = "gpt-oss-120b")]
GptOss120B,
+ // NVIDIA Nemotron models
+ #[serde(rename = "nemotron-super-3-120b")]
+ NemotronSuper3_120B,
+ #[serde(rename = "nemotron-nano-3-30b")]
+ NemotronNano3_30B,
+
// MiniMax models
#[serde(rename = "minimax-m2")]
MiniMaxM2,
+ #[serde(rename = "minimax-m2-1")]
+ MiniMaxM2_1,
+ #[serde(rename = "minimax-m2-5")]
+ MiniMaxM2_5,
+
+ // Z.AI GLM models
+ #[serde(rename = "glm-5")]
+ GLM5,
+ #[serde(rename = "glm-4-7")]
+ GLM4_7,
+ #[serde(rename = "glm-4-7-flash")]
+ GLM4_7Flash,
// Moonshot models
#[serde(rename = "kimi-k2-thinking")]
@@ -217,6 +239,8 @@ impl Model {
Self::MagistralSmall => "magistral-small",
Self::MistralLarge3 => "mistral-large-3",
Self::PixtralLarge => "pixtral-large",
+ Self::Devstral2_123B => "devstral-2-123b",
+ Self::Ministral14B => "ministral-14b",
Self::Qwen3_32B => "qwen3-32b",
Self::Qwen3VL235B => "qwen3-vl-235b",
Self::Qwen3_235B => "qwen3-235b",
@@ -230,7 +254,14 @@ impl Model {
Self::Nova2Lite => "nova-2-lite",
Self::GptOss20B => "gpt-oss-20b",
Self::GptOss120B => "gpt-oss-120b",
+ Self::NemotronSuper3_120B => "nemotron-super-3-120b",
+ Self::NemotronNano3_30B => "nemotron-nano-3-30b",
Self::MiniMaxM2 => "minimax-m2",
+ Self::MiniMaxM2_1 => "minimax-m2-1",
+ Self::MiniMaxM2_5 => "minimax-m2-5",
+ Self::GLM5 => "glm-5",
+ Self::GLM4_7 => "glm-4-7",
+ Self::GLM4_7Flash => "glm-4-7-flash",
Self::KimiK2Thinking => "kimi-k2-thinking",
Self::KimiK2_5 => "kimi-k2-5",
Self::DeepSeekR1 => "deepseek-r1",
@@ -257,6 +288,8 @@ impl Model {
Self::MagistralSmall => "mistral.magistral-small-2509",
Self::MistralLarge3 => "mistral.mistral-large-3-675b-instruct",
Self::PixtralLarge => "mistral.pixtral-large-2502-v1:0",
+ Self::Devstral2_123B => "mistral.devstral-2-123b",
+ Self::Ministral14B => "mistral.ministral-3-14b-instruct",
Self::Qwen3VL235B => "qwen.qwen3-vl-235b-a22b",
Self::Qwen3_32B => "qwen.qwen3-32b-v1:0",
Self::Qwen3_235B => "qwen.qwen3-235b-a22b-2507-v1:0",
@@ -270,7 +303,14 @@ impl Model {
Self::Nova2Lite => "amazon.nova-2-lite-v1:0",
Self::GptOss20B => "openai.gpt-oss-20b-1:0",
Self::GptOss120B => "openai.gpt-oss-120b-1:0",
+ Self::NemotronSuper3_120B => "nvidia.nemotron-super-3-120b",
+ Self::NemotronNano3_30B => "nvidia.nemotron-nano-3-30b",
Self::MiniMaxM2 => "minimax.minimax-m2",
+ Self::MiniMaxM2_1 => "minimax.minimax-m2.1",
+ Self::MiniMaxM2_5 => "minimax.minimax-m2.5",
+ Self::GLM5 => "zai.glm-5",
+ Self::GLM4_7 => "zai.glm-4.7",
+ Self::GLM4_7Flash => "zai.glm-4.7-flash",
Self::KimiK2Thinking => "moonshot.kimi-k2-thinking",
Self::KimiK2_5 => "moonshotai.kimi-k2.5",
Self::DeepSeekR1 => "deepseek.r1-v1:0",
@@ -297,6 +337,8 @@ impl Model {
Self::MagistralSmall => "Magistral Small",
Self::MistralLarge3 => "Mistral Large 3",
Self::PixtralLarge => "Pixtral Large",
+ Self::Devstral2_123B => "Devstral 2 123B",
+ Self::Ministral14B => "Ministral 14B",
Self::Qwen3VL235B => "Qwen3 VL 235B",
Self::Qwen3_32B => "Qwen3 32B",
Self::Qwen3_235B => "Qwen3 235B",
@@ -310,7 +352,14 @@ impl Model {
Self::Nova2Lite => "Amazon Nova 2 Lite",
Self::GptOss20B => "GPT OSS 20B",
Self::GptOss120B => "GPT OSS 120B",
+ Self::NemotronSuper3_120B => "Nemotron Super 3 120B",
+ Self::NemotronNano3_30B => "Nemotron Nano 3 30B",
Self::MiniMaxM2 => "MiniMax M2",
+ Self::MiniMaxM2_1 => "MiniMax M2.1",
+ Self::MiniMaxM2_5 => "MiniMax M2.5",
+ Self::GLM5 => "GLM 5",
+ Self::GLM4_7 => "GLM 4.7",
+ Self::GLM4_7Flash => "GLM 4.7 Flash",
Self::KimiK2Thinking => "Kimi K2 Thinking",
Self::KimiK2_5 => "Kimi K2.5",
Self::DeepSeekR1 => "DeepSeek R1",
@@ -338,6 +387,7 @@ impl Model {
Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000,
Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000,
Self::MagistralSmall | Self::MistralLarge3 | Self::PixtralLarge => 128_000,
+ Self::Devstral2_123B | Self::Ministral14B => 256_000,
Self::Qwen3_32B
| Self::Qwen3VL235B
| Self::Qwen3_235B
@@ -349,7 +399,9 @@ impl Model {
Self::NovaPremier => 1_000_000,
Self::Nova2Lite => 300_000,
Self::GptOss20B | Self::GptOss120B => 128_000,
- Self::MiniMaxM2 => 128_000,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => 262_000,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => 196_000,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => 203_000,
Self::KimiK2Thinking | Self::KimiK2_5 => 128_000,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => 128_000,
Self::Custom { max_tokens, .. } => *max_tokens,
@@ -373,6 +425,7 @@ impl Model {
| Self::MagistralSmall
| Self::MistralLarge3
| Self::PixtralLarge => 8_192,
+ Self::Devstral2_123B | Self::Ministral14B => 131_000,
Self::Qwen3_32B
| Self::Qwen3VL235B
| Self::Qwen3_235B
@@ -382,7 +435,9 @@ impl Model {
| Self::Qwen3Coder480B => 8_192,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => 5_000,
Self::GptOss20B | Self::GptOss120B => 16_000,
- Self::MiniMaxM2 => 16_000,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => 131_000,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => 98_000,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => 101_000,
Self::KimiK2Thinking | Self::KimiK2_5 => 16_000,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => 16_000,
Self::Custom {
@@ -419,6 +474,7 @@ impl Model {
| Self::ClaudeSonnet4_6 => true,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true,
Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true,
+ Self::Devstral2_123B | Self::Ministral14B => true,
// Gemma accepts toolConfig without error but produces unreliable tool
// calls -- malformed JSON args, hallucinated tool names, dropped calls.
Self::Qwen3_32B
@@ -428,7 +484,9 @@ impl Model {
| Self::Qwen3Coder30B
| Self::Qwen3CoderNext
| Self::Qwen3Coder480B => true,
- Self::MiniMaxM2 => true,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => true,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => true,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => true,
Self::KimiK2Thinking | Self::KimiK2_5 => true,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => true,
_ => false,
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index 7bbaccb22e0e6c7508240186103e216f83be2f0c..532fe38f7df1f686730ed862a81806e9a531e156 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -36,7 +36,6 @@ gpui_tokio.workspace = true
http_client.workspace = true
http_client_tls.workspace = true
httparse = "1.10"
-language_model.workspace = true
log.workspace = true
parking_lot.workspace = true
paths.workspace = true
diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs
index dfd9963a0ee52d167f8d4edb0b850f4debed7fd4..05ca974f80438542b232262dd375e0e38ab4327c 100644
--- a/crates/client/src/client.rs
+++ b/crates/client/src/client.rs
@@ -14,6 +14,7 @@ use async_tungstenite::tungstenite::{
http::{HeaderValue, Request, StatusCode},
};
use clock::SystemClock;
+use cloud_api_client::LlmApiToken;
use cloud_api_client::websocket_protocol::MessageToClient;
use cloud_api_client::{ClientApiError, CloudApiClient};
use cloud_api_types::OrganizationId;
@@ -26,7 +27,6 @@ use futures::{
};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env};
-use language_model::LlmApiToken;
use parking_lot::{Mutex, RwLock};
use postage::watch;
use proxy::connect_proxy_stream;
diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs
index f62aa6dd4dc3462bc3a0f6f46c35f0e4e5499816..70457679e4b965e3251ae4861d3052bfa41fd65a 100644
--- a/crates/client/src/llm_token.rs
+++ b/crates/client/src/llm_token.rs
@@ -1,10 +1,10 @@
use super::{Client, UserStore};
+use cloud_api_client::LlmApiToken;
use cloud_api_types::websocket_protocol::MessageToClient;
use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
};
-use language_model::LlmApiToken;
use std::sync::Arc;
pub trait NeedsLlmTokenRefresh {
diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml
index 78c684e3e54ee29a5f3f3ae5620d4a52b445f92e..cf293d83f848e1266dec977c0925af7f66608ce6 100644
--- a/crates/cloud_api_client/Cargo.toml
+++ b/crates/cloud_api_client/Cargo.toml
@@ -20,5 +20,6 @@ gpui_tokio.workspace = true
http_client.workspace = true
parking_lot.workspace = true
serde_json.workspace = true
+smol.workspace = true
thiserror.workspace = true
yawc.workspace = true
diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs
index 13d67838b216f4990f15ec22c1701aa7aef9dbf2..8c605bb3490ef5c7aea6e96045680338e8344a83 100644
--- a/crates/cloud_api_client/src/cloud_api_client.rs
+++ b/crates/cloud_api_client/src/cloud_api_client.rs
@@ -1,3 +1,4 @@
+mod llm_token;
mod websocket;
use std::sync::Arc;
@@ -18,6 +19,8 @@ use yawc::WebSocket;
use crate::websocket::Connection;
+pub use llm_token::LlmApiToken;
+
struct Credentials {
user_id: u32,
access_token: String,
diff --git a/crates/cloud_api_client/src/llm_token.rs b/crates/cloud_api_client/src/llm_token.rs
new file mode 100644
index 0000000000000000000000000000000000000000..711e0d51b89bf34db255d7cb1e58483c9de340fc
--- /dev/null
+++ b/crates/cloud_api_client/src/llm_token.rs
@@ -0,0 +1,74 @@
+use std::sync::Arc;
+
+use cloud_api_types::OrganizationId;
+use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard};
+
+use crate::{ClientApiError, CloudApiClient};
+
+#[derive(Clone, Default)]
+pub struct LlmApiToken(Arc>>);
+
+impl LlmApiToken {
+ pub async fn acquire(
+ &self,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ let lock = self.0.upgradable_read().await;
+ if let Some(token) = lock.as_ref() {
+ Ok(token.to_string())
+ } else {
+ Self::fetch(
+ RwLockUpgradableReadGuard::upgrade(lock).await,
+ client,
+ system_id,
+ organization_id,
+ )
+ .await
+ }
+ }
+
+ pub async fn refresh(
+ &self,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ Self::fetch(self.0.write().await, client, system_id, organization_id).await
+ }
+
+ /// Clears the existing token before attempting to fetch a new one.
+ ///
+ /// Used when switching organizations so that a failed refresh doesn't
+ /// leave a token for the wrong organization.
+ pub async fn clear_and_refresh(
+ &self,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ let mut lock = self.0.write().await;
+ *lock = None;
+ Self::fetch(lock, client, system_id, organization_id).await
+ }
+
+ async fn fetch(
+ mut lock: RwLockWriteGuard<'_, Option>,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ let result = client.create_llm_token(system_id, organization_id).await;
+ match result {
+ Ok(response) => {
+ *lock = Some(response.token.0.clone());
+ Ok(response.token.0)
+ }
+ Err(err) => {
+ *lock = None;
+ Err(err)
+ }
+ }
+ }
+}
diff --git a/crates/cloud_llm_client/Cargo.toml b/crates/cloud_llm_client/Cargo.toml
index a7b4f925a9302296e8fe25a14177a583e5f44b33..7cc59f255abeb27c6e35a2064654d8eca1a581fe 100644
--- a/crates/cloud_llm_client/Cargo.toml
+++ b/crates/cloud_llm_client/Cargo.toml
@@ -7,6 +7,7 @@ license = "Apache-2.0"
[features]
test-support = []
+predict-edits = ["dep:zeta_prompt"]
[lints]
workspace = true
@@ -20,6 +21,6 @@ serde = { workspace = true, features = ["derive", "rc"] }
serde_json.workspace = true
strum = { workspace = true, features = ["derive"] }
uuid = { workspace = true, features = ["serde"] }
-zeta_prompt.workspace = true
+zeta_prompt = { workspace = true, optional = true }
diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs
index 35eb3f2b80dd400558b1f027781f5b8cf63bb6cb..ac8bdd462a9c4754ef42a6afa41f1bef8b5bbe6a 100644
--- a/crates/cloud_llm_client/src/cloud_llm_client.rs
+++ b/crates/cloud_llm_client/src/cloud_llm_client.rs
@@ -1,3 +1,4 @@
+#[cfg(feature = "predict-edits")]
pub mod predict_edits_v3;
use std::str::FromStr;
diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs
index 2fa67b072f1c3d49ef5ca1b90056fd08d57df1ba..c273005264d0a53b6a083a4013f7597a56919016 100644
--- a/crates/collab/tests/integration/git_tests.rs
+++ b/crates/collab/tests/integration/git_tests.rs
@@ -269,9 +269,11 @@ async fn test_remote_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
- "feature-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_directory.join("feature-branch"),
- Some("abc123".to_string()),
)
})
})
@@ -323,9 +325,11 @@ async fn test_remote_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
- "bugfix-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "bugfix-branch".to_string(),
+ base_sha: None,
+ },
worktree_directory.join("bugfix-branch"),
- None,
)
})
})
diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
index 0796323fc5b3d8f6b1cbcb0e108a7d573240f446..d478402a9d66ca9fba4e8f9517cb62898754e677 100644
--- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
+++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
@@ -473,9 +473,11 @@ async fn test_ssh_collaboration_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repo, _| {
repo.create_worktree(
- "feature-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_directory.join("feature-branch"),
- Some("abc123".to_string()),
)
})
})
diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml
index efcba05456955e308e5a00e938bf3092d894efeb..920f620e0ea2d48f514c5e0af598add193f80d98 100644
--- a/crates/collab_ui/Cargo.toml
+++ b/crates/collab_ui/Cargo.toml
@@ -32,7 +32,6 @@ test-support = [
anyhow.workspace = true
call.workspace = true
channel.workspace = true
-chrono.workspace = true
client.workspace = true
collections.workspace = true
db.workspace = true
@@ -41,7 +40,6 @@ futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
livekit_client.workspace = true
-log.workspace = true
menu.workspace = true
notifications.workspace = true
picker.workspace = true
@@ -56,7 +54,6 @@ telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
time.workspace = true
-time_format.workspace = true
title_bar.workspace = true
ui.workspace = true
util.workspace = true
diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs
index 8d0cdf351163dadf0ac8cbf6a8dc04886f30f583..1cff27ac6b2f3c61f7a90c4a9ca6749d4b1e48b7 100644
--- a/crates/collab_ui/src/collab_panel.rs
+++ b/crates/collab_ui/src/collab_panel.rs
@@ -6,7 +6,7 @@ use crate::{CollaborationPanelSettings, channel_view::ChannelView};
use anyhow::Context as _;
use call::ActiveCall;
use channel::{Channel, ChannelEvent, ChannelStore};
-use client::{ChannelId, Client, Contact, User, UserStore};
+use client::{ChannelId, Client, Contact, Notification, User, UserStore};
use collections::{HashMap, HashSet};
use contact_finder::ContactFinder;
use db::kvp::KeyValueStore;
@@ -21,6 +21,7 @@ use gpui::{
};
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
+use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
use project::{Fs, Project};
use rpc::{
ErrorCode, ErrorExt,
@@ -29,19 +30,23 @@ use rpc::{
use serde::{Deserialize, Serialize};
use settings::Settings;
use smallvec::SmallVec;
-use std::{mem, sync::Arc};
+use std::{mem, sync::Arc, time::Duration};
use theme::ActiveTheme;
use theme_settings::ThemeSettings;
use ui::{
- Avatar, AvatarAvailabilityIndicator, ContextMenu, CopyButton, Facepile, HighlightedLabel,
- IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*, tooltip_container,
+ Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile,
+ HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*,
+ tooltip_container,
};
use util::{ResultExt, TryFutureExt, maybe};
use workspace::{
CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById,
ScreenShare, ShareProject, Workspace,
dock::{DockPosition, Panel, PanelEvent},
- notifications::{DetachAndPromptErr, NotifyResultExt},
+ notifications::{
+ DetachAndPromptErr, Notification as WorkspaceNotification, NotificationId, NotifyResultExt,
+ SuppressEvent,
+ },
};
const FILTER_OCCUPIED_CHANNELS_KEY: &str = "filter_occupied_channels";
@@ -87,6 +92,7 @@ struct ChannelMoveClipboard {
}
const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
+const TOAST_DURATION: Duration = Duration::from_secs(5);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| {
@@ -267,6 +273,9 @@ pub struct CollabPanel {
collapsed_channels: Vec,
filter_occupied_channels: bool,
workspace: WeakEntity,
+ notification_store: Entity,
+ current_notification_toast: Option<(u64, Task<()>)>,
+ mark_as_read_tasks: HashMap>>,
}
#[derive(Serialize, Deserialize)]
@@ -394,6 +403,9 @@ impl CollabPanel {
channel_editing_state: None,
selection: None,
channel_store: ChannelStore::global(cx),
+ notification_store: NotificationStore::global(cx),
+ current_notification_toast: None,
+ mark_as_read_tasks: HashMap::default(),
user_store: workspace.user_store().clone(),
project: workspace.project().clone(),
subscriptions: Vec::default(),
@@ -437,6 +449,11 @@ impl CollabPanel {
}
},
));
+ this.subscriptions.push(cx.subscribe_in(
+ &this.notification_store,
+ window,
+ Self::on_notification_event,
+ ));
this
})
@@ -1181,7 +1198,7 @@ impl CollabPanel {
.into();
ListItem::new(project_id as usize)
- .height(px(24.))
+ .height(rems_from_px(24.))
.toggle_state(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.workspace
@@ -1222,7 +1239,7 @@ impl CollabPanel {
let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
ListItem::new(("screen", id))
- .height(px(24.))
+ .height(rems_from_px(24.))
.toggle_state(is_selected)
.start_slot(
h_flex()
@@ -1269,7 +1286,7 @@ impl CollabPanel {
let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
ListItem::new("channel-notes")
- .height(px(24.))
+ .height(rems_from_px(24.))
.toggle_state(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_channel_notes(channel_id, window, cx);
@@ -2665,26 +2682,28 @@ impl CollabPanel {
window: &mut Window,
cx: &mut Context,
) -> AnyElement {
- let entry = &self.entries[ix];
+ let entry = self.entries[ix].clone();
let is_selected = self.selection == Some(ix);
match entry {
ListEntry::Header(section) => {
- let is_collapsed = self.collapsed_sections.contains(section);
- self.render_header(*section, is_selected, is_collapsed, cx)
+ let is_collapsed = self.collapsed_sections.contains(§ion);
+ self.render_header(section, is_selected, is_collapsed, cx)
+ .into_any_element()
+ }
+ ListEntry::Contact { contact, calling } => {
+ self.mark_contact_request_accepted_notifications_read(contact.user.id, cx);
+ self.render_contact(&contact, calling, is_selected, cx)
.into_any_element()
}
- ListEntry::Contact { contact, calling } => self
- .render_contact(contact, *calling, is_selected, cx)
- .into_any_element(),
ListEntry::ContactPlaceholder => self
.render_contact_placeholder(is_selected, cx)
.into_any_element(),
ListEntry::IncomingRequest(user) => self
- .render_contact_request(user, true, is_selected, cx)
+ .render_contact_request(&user, true, is_selected, cx)
.into_any_element(),
ListEntry::OutgoingRequest(user) => self
- .render_contact_request(user, false, is_selected, cx)
+ .render_contact_request(&user, false, is_selected, cx)
.into_any_element(),
ListEntry::Channel {
channel,
@@ -2694,9 +2713,9 @@ impl CollabPanel {
..
} => self
.render_channel(
- channel,
- *depth,
- *has_children,
+ &channel,
+ depth,
+ has_children,
is_selected,
ix,
string_match.as_ref(),
@@ -2704,10 +2723,10 @@ impl CollabPanel {
)
.into_any_element(),
ListEntry::ChannelEditor { depth } => self
- .render_channel_editor(*depth, window, cx)
+ .render_channel_editor(depth, window, cx)
.into_any_element(),
ListEntry::ChannelInvite(channel) => self
- .render_channel_invite(channel, is_selected, cx)
+ .render_channel_invite(&channel, is_selected, cx)
.into_any_element(),
ListEntry::CallParticipant {
user,
@@ -2715,7 +2734,7 @@ impl CollabPanel {
is_pending,
role,
} => self
- .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
+ .render_call_participant(&user, peer_id, is_pending, role, is_selected, cx)
.into_any_element(),
ListEntry::ParticipantProject {
project_id,
@@ -2724,20 +2743,20 @@ impl CollabPanel {
is_last,
} => self
.render_participant_project(
- *project_id,
- worktree_root_names,
- *host_user_id,
- *is_last,
+ project_id,
+ &worktree_root_names,
+ host_user_id,
+ is_last,
is_selected,
window,
cx,
)
.into_any_element(),
ListEntry::ParticipantScreen { peer_id, is_last } => self
- .render_participant_screen(*peer_id, *is_last, is_selected, window, cx)
+ .render_participant_screen(peer_id, is_last, is_selected, window, cx)
.into_any_element(),
ListEntry::ChannelNotes { channel_id } => self
- .render_channel_notes(*channel_id, is_selected, window, cx)
+ .render_channel_notes(channel_id, is_selected, window, cx)
.into_any_element(),
}
}
@@ -2846,11 +2865,11 @@ impl CollabPanel {
}
};
- Some(channel.name.as_ref())
+ Some(channel.name.clone())
});
if let Some(name) = channel_name {
- SharedString::from(name.to_string())
+ name
} else {
SharedString::from("Current Call")
}
@@ -3210,7 +3229,7 @@ impl CollabPanel {
(IconName::Star, Color::Default, "Add to Favorites")
};
- let height = px(24.);
+ let height = rems_from_px(24.);
h_flex()
.id(ix)
@@ -3397,6 +3416,178 @@ impl CollabPanel {
item.child(self.channel_name_editor.clone())
}
}
+
+ fn on_notification_event(
+ &mut self,
+ _: &Entity,
+ event: &NotificationEvent,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ match event {
+ NotificationEvent::NewNotification { entry } => {
+ self.add_toast(entry, cx);
+ cx.notify();
+ }
+ NotificationEvent::NotificationRemoved { entry }
+ | NotificationEvent::NotificationRead { entry } => {
+ self.remove_toast(entry.id, cx);
+ cx.notify();
+ }
+ NotificationEvent::NotificationsUpdated { .. } => {
+ cx.notify();
+ }
+ }
+ }
+
+ fn present_notification(
+ &self,
+ entry: &NotificationEntry,
+ cx: &App,
+ ) -> Option<(Option>, String)> {
+ let user_store = self.user_store.read(cx);
+ match &entry.notification {
+ Notification::ContactRequest { sender_id } => {
+ let requester = user_store.get_cached_user(*sender_id)?;
+ Some((
+ Some(requester.clone()),
+ format!("{} wants to add you as a contact", requester.github_login),
+ ))
+ }
+ Notification::ContactRequestAccepted { responder_id } => {
+ let responder = user_store.get_cached_user(*responder_id)?;
+ Some((
+ Some(responder.clone()),
+ format!("{} accepted your contact request", responder.github_login),
+ ))
+ }
+ Notification::ChannelInvitation {
+ channel_name,
+ inviter_id,
+ ..
+ } => {
+ let inviter = user_store.get_cached_user(*inviter_id)?;
+ Some((
+ Some(inviter.clone()),
+ format!(
+ "{} invited you to join the #{channel_name} channel",
+ inviter.github_login
+ ),
+ ))
+ }
+ }
+ }
+
+ fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut Context) {
+ let Some((actor, text)) = self.present_notification(entry, cx) else {
+ return;
+ };
+
+ let notification = entry.notification.clone();
+ let needs_response = matches!(
+ notification,
+ Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. }
+ );
+
+ let notification_id = entry.id;
+
+ self.current_notification_toast = Some((
+ notification_id,
+ cx.spawn(async move |this, cx| {
+ cx.background_executor().timer(TOAST_DURATION).await;
+ this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
+ .ok();
+ }),
+ ));
+
+ let collab_panel = cx.entity().downgrade();
+ self.workspace
+ .update(cx, |workspace, cx| {
+ let id = NotificationId::unique::();
+
+ workspace.dismiss_notification(&id, cx);
+ workspace.show_notification(id, cx, |cx| {
+ let workspace = cx.entity().downgrade();
+ cx.new(|cx| CollabNotificationToast {
+ actor,
+ text,
+ notification: needs_response.then(|| notification),
+ workspace,
+ collab_panel: collab_panel.clone(),
+ focus_handle: cx.focus_handle(),
+ })
+ })
+ })
+ .ok();
+ }
+
+ fn mark_notification_read(&mut self, notification_id: u64, cx: &mut Context) {
+ let client = self.client.clone();
+ self.mark_as_read_tasks
+ .entry(notification_id)
+ .or_insert_with(|| {
+ cx.spawn(async move |this, cx| {
+ let request_result = client
+ .request(proto::MarkNotificationRead { notification_id })
+ .await;
+
+ this.update(cx, |this, _| {
+ this.mark_as_read_tasks.remove(¬ification_id);
+ })?;
+
+ request_result?;
+ Ok(())
+ })
+ });
+ }
+
+ fn mark_contact_request_accepted_notifications_read(
+ &mut self,
+ contact_user_id: u64,
+ cx: &mut Context,
+ ) {
+ let notification_ids = self.notification_store.read_with(cx, |store, _| {
+ (0..store.notification_count())
+ .filter_map(|index| {
+ let entry = store.notification_at(index)?;
+ if entry.is_read {
+ return None;
+ }
+
+ match &entry.notification {
+ Notification::ContactRequestAccepted { responder_id }
+ if *responder_id == contact_user_id =>
+ {
+ Some(entry.id)
+ }
+ _ => None,
+ }
+ })
+ .collect::>()
+ });
+
+ for notification_id in notification_ids {
+ self.mark_notification_read(notification_id, cx);
+ }
+ }
+
+ fn remove_toast(&mut self, notification_id: u64, cx: &mut Context) {
+ if let Some((current_id, _)) = &self.current_notification_toast {
+ if *current_id == notification_id {
+ self.dismiss_toast(cx);
+ }
+ }
+ }
+
+ fn dismiss_toast(&mut self, cx: &mut Context) {
+ self.current_notification_toast.take();
+ self.workspace
+ .update(cx, |workspace, cx| {
+ let id = NotificationId::unique::();
+ workspace.dismiss_notification(&id, cx)
+ })
+ .ok();
+ }
}
fn render_tree_branch(
@@ -3516,12 +3707,38 @@ impl Panel for CollabPanel {
CollaborationPanelSettings::get_global(cx).default_width
}
+ fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut Context) {
+ if active && self.current_notification_toast.is_some() {
+ self.current_notification_toast.take();
+ let workspace = self.workspace.clone();
+ cx.defer(move |cx| {
+ workspace
+ .update(cx, |workspace, cx| {
+ let id = NotificationId::unique::();
+ workspace.dismiss_notification(&id, cx)
+ })
+ .ok();
+ });
+ }
+ }
+
fn icon(&self, _window: &Window, cx: &App) -> Option {
CollaborationPanelSettings::get_global(cx)
.button
.then_some(ui::IconName::UserGroup)
}
+ fn icon_label(&self, _window: &Window, cx: &App) -> Option {
+ let user_store = self.user_store.read(cx);
+ let count = user_store.incoming_contact_requests().len()
+ + self.channel_store.read(cx).channel_invitations().len();
+ if count == 0 {
+ None
+ } else {
+ Some(count.to_string())
+ }
+ }
+
fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
Some("Collab Panel")
}
@@ -3702,6 +3919,101 @@ impl Render for JoinChannelTooltip {
}
}
+pub struct CollabNotificationToast {
+ actor: Option>,
+ text: String,
+ notification: Option,
+ workspace: WeakEntity