Merge branch 'main' into bedrock-thinking-toggle-integration

Shardul Vaidya created

Change summary

.github/DISCUSSION_TEMPLATE/feature-requests.yml                          |    2 
.github/workflows/assign-reviewers.yml                                    |   81 
.github/workflows/bump_patch_version.yml                                  |    2 
.github/workflows/deploy_collab.yml                                       |    6 
.github/workflows/extension_auto_bump.yml                                 |   72 
.github/workflows/extension_bump.yml                                      |   49 
.github/workflows/extension_tests.yml                                     |   61 
.github/workflows/extension_workflow_rollout.yml                          |  145 
.github/workflows/run_tests.yml                                           |   32 
CONTRIBUTING.md                                                           |    2 
Cargo.lock                                                                |  281 
Cargo.toml                                                                |   20 
Dockerfile-collab                                                         |    6 
assets/icons/archive.svg                                                  |    5 
assets/icons/git_merge_conflict.svg                                       |    7 
assets/icons/list_collapse.svg                                            |    8 
assets/icons/thread.svg                                                   |    3 
assets/icons/threads_sidebar_left_closed.svg                              |    5 
assets/icons/threads_sidebar_left_open.svg                                |    5 
assets/icons/threads_sidebar_right_closed.svg                             |    5 
assets/icons/threads_sidebar_right_open.svg                               |    5 
assets/icons/workspace_nav_closed.svg                                     |    5 
assets/icons/workspace_nav_open.svg                                       |    5 
assets/keymaps/default-linux.json                                         |    7 
assets/keymaps/default-macos.json                                         |    5 
assets/keymaps/default-windows.json                                       |    5 
assets/keymaps/linux/jetbrains.json                                       |    7 
assets/keymaps/macos/jetbrains.json                                       |    8 
assets/keymaps/vim.json                                                   |    1 
assets/settings/default.json                                              |    9 
crates/acp_thread/Cargo.toml                                              |    2 
crates/acp_thread/src/acp_thread.rs                                       |  266 
crates/acp_thread/src/connection.rs                                       |   17 
crates/acp_thread/src/mention.rs                                          |   19 
crates/action_log/Cargo.toml                                              |    2 
crates/action_log/src/action_log.rs                                       |   47 
crates/activity_indicator/Cargo.toml                                      |    2 
crates/agent/Cargo.toml                                                   |    6 
crates/agent/src/agent.rs                                                 |  466 
crates/agent/src/db.rs                                                    |    6 
crates/agent/src/edit_agent/evals.rs                                      |    2 
crates/agent/src/native_agent_server.rs                                   |    8 
crates/agent/src/tests/mod.rs                                             |  305 
crates/agent/src/tests/test_tools.rs                                      |   73 
crates/agent/src/thread.rs                                                |  106 
crates/agent/src/thread_store.rs                                          |   32 
crates/agent/src/tool_permissions.rs                                      |    1 
crates/agent/src/tools/streaming_edit_file_tool.rs                        |  132 
crates/agent_servers/Cargo.toml                                           |    2 
crates/agent_servers/src/acp.rs                                           |   82 
crates/agent_servers/src/agent_servers.rs                                 |   11 
crates/agent_servers/src/custom.rs                                        |  335 
crates/agent_servers/src/e2e_tests.rs                                     |    6 
crates/agent_settings/Cargo.toml                                          |    2 
crates/agent_settings/src/agent_settings.rs                               |    4 
crates/agent_ui/Cargo.toml                                                |    7 
crates/agent_ui/src/agent_configuration.rs                                |   59 
crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs         |   18 
crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs |    8 
crates/agent_ui/src/agent_connection_store.rs                             |  182 
crates/agent_ui/src/agent_diff.rs                                         |   11 
crates/agent_ui/src/agent_model_selector.rs                               |   17 
crates/agent_ui/src/agent_panel.rs                                        |  675 
crates/agent_ui/src/agent_registry_ui.rs                                  |   17 
crates/agent_ui/src/agent_ui.rs                                           |  128 
crates/agent_ui/src/completion_provider.rs                                |  208 
crates/agent_ui/src/config_options.rs                                     |    5 
crates/agent_ui/src/connection_view.rs                                    |  517 
crates/agent_ui/src/connection_view/thread_view.rs                        |  401 
crates/agent_ui/src/entry_view_state.rs                                   |    3 
crates/agent_ui/src/external_source_prompt.rs                             |  162 
crates/agent_ui/src/inline_assistant.rs                                   |   35 
crates/agent_ui/src/inline_prompt_editor.rs                               |    8 
crates/agent_ui/src/mention_set.rs                                        |   62 
crates/agent_ui/src/message_editor.rs                                     |  193 
crates/agent_ui/src/mode_selector.rs                                      |    5 
crates/agent_ui/src/model_selector_popover.rs                             |   63 
crates/agent_ui/src/profile_selector.rs                                   |   45 
crates/agent_ui/src/sidebar.rs                                            | 4926 
crates/agent_ui/src/text_thread_editor.rs                                 |   29 
crates/agent_ui/src/text_thread_history.rs                                |    4 
crates/agent_ui/src/thread_history.rs                                     |  876 
crates/agent_ui/src/thread_history_view.rs                                |  886 
crates/agent_ui/src/threads_archive_view.rs                               |  691 
crates/agent_ui/src/ui/acp_onboarding_modal.rs                            |    9 
crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs                   |    9 
crates/agent_ui/src/ui/hold_for_default.rs                                |   19 
crates/agent_ui/src/ui/mention_crease.rs                                  |   19 
crates/ai_onboarding/src/ai_onboarding.rs                                 |   23 
crates/ai_onboarding/src/ai_upsell_card.rs                                |   31 
crates/ai_onboarding/src/plan_definitions.rs                              |    6 
crates/anthropic/Cargo.toml                                               |    6 
crates/anthropic/src/anthropic.rs                                         |    2 
crates/assistant_text_thread/Cargo.toml                                   |    2 
crates/assistant_text_thread/src/text_thread.rs                           |    2 
crates/audio/src/audio.rs                                                 |   30 
crates/audio/src/audio_settings.rs                                        |    4 
crates/auto_update/src/auto_update.rs                                     |   22 
crates/breadcrumbs/src/breadcrumbs.rs                                     |   10 
crates/buffer_diff/Cargo.toml                                             |    2 
crates/call/Cargo.toml                                                    |    2 
crates/channel/src/channel_buffer.rs                                      |    2 
crates/channel/src/channel_store.rs                                       |    4 
crates/cli/src/cli.rs                                                     |    3 
crates/client/src/test.rs                                                 |    8 
crates/client/src/user.rs                                                 |   35 
crates/cloud_api_types/src/cloud_api_types.rs                             |    5 
crates/cloud_api_types/src/plan.rs                                        |    1 
crates/cloud_llm_client/Cargo.toml                                        |    4 
crates/codestral/Cargo.toml                                               |    1 
crates/codestral/src/codestral.rs                                         |   45 
crates/collab/Cargo.toml                                                  |   16 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql            |    1 
crates/collab/migrations/20251208000000_test_schema.sql                   |   16 
crates/collab/src/db/queries/projects.rs                                  |    9 
crates/collab/src/db/queries/rooms.rs                                     |    5 
crates/collab/src/db/tables/project_repository.rs                         |    2 
crates/collab/src/rpc.rs                                                  |   20 
crates/collab/tests/integration/editor_tests.rs                           |   51 
crates/collab/tests/integration/git_tests.rs                              |  294 
crates/collab/tests/integration/remote_editing_collaboration_tests.rs     |  116 
crates/collab_ui/Cargo.toml                                               |    8 
crates/collab_ui/src/collab_panel.rs                                      |   17 
crates/collab_ui/src/notification_panel.rs                                |    4 
crates/command_palette/Cargo.toml                                         |    6 
crates/copilot/Cargo.toml                                                 |    4 
crates/copilot/src/copilot.rs                                             |    3 
crates/copilot_chat/Cargo.toml                                            |    1 
crates/copilot_chat/src/copilot_chat.rs                                   |  185 
crates/copilot_chat/src/responses.rs                                      |   18 
crates/copilot_ui/src/sign_in.rs                                          |   27 
crates/crashes/src/crashes.rs                                             |   34 
crates/dap/Cargo.toml                                                     |    3 
crates/debugger_ui/src/debugger_panel.rs                                  |   36 
crates/debugger_ui/src/persistence.rs                                     |   37 
crates/debugger_ui/src/session/running.rs                                 |  328 
crates/debugger_ui/src/tests.rs                                           |    8 
crates/debugger_ui/src/tests/debugger_panel.rs                            |   74 
crates/debugger_ui/src/tests/stack_frame_list.rs                          |    4 
crates/dev_container/Cargo.toml                                           |    2 
crates/diagnostics/Cargo.toml                                             |    2 
crates/diagnostics/src/diagnostic_renderer.rs                             |    2 
crates/diagnostics/src/diagnostics.rs                                     |    2 
crates/diagnostics/src/items.rs                                           |    3 
crates/edit_prediction/Cargo.toml                                         |    2 
crates/edit_prediction/src/capture_example.rs                             |   44 
crates/edit_prediction/src/cursor_excerpt.rs                              |  565 
crates/edit_prediction/src/edit_prediction.rs                             |  394 
crates/edit_prediction/src/edit_prediction_tests.rs                       |  601 
crates/edit_prediction/src/fim.rs                                         |   39 
crates/edit_prediction/src/mercury.rs                                     |  146 
crates/edit_prediction/src/prediction.rs                                  |    4 
crates/edit_prediction/src/sweep_ai.rs                                    |    4 
crates/edit_prediction/src/zeta.rs                                        |  281 
crates/edit_prediction_cli/src/format_prompt.rs                           |  277 
crates/edit_prediction_cli/src/headless.rs                                |    2 
crates/edit_prediction_cli/src/load_project.rs                            |   26 
crates/edit_prediction_cli/src/main.rs                                    |   77 
crates/edit_prediction_cli/src/parse_output.rs                            |  162 
crates/edit_prediction_cli/src/predict.rs                                 |   96 
crates/edit_prediction_cli/src/prompts/teacher_multi_region.md            |  366 
crates/edit_prediction_cli/src/pull_examples.rs                           |  263 
crates/edit_prediction_cli/src/repair.rs                                  |   21 
crates/edit_prediction_cli/src/retrieve_context.rs                        |   21 
crates/edit_prediction_cli/src/reversal_tracking.rs                       |    4 
crates/edit_prediction_context/Cargo.toml                                 |    2 
crates/edit_prediction_ui/Cargo.toml                                      |   10 
crates/edit_prediction_ui/src/edit_prediction_button.rs                   |  198 
crates/edit_prediction_ui/src/rate_prediction_modal.rs                    |   16 
crates/editor/Cargo.toml                                                  |    9 
crates/editor/src/bracket_colorization.rs                                 |   54 
crates/editor/src/display_map.rs                                          |   50 
crates/editor/src/display_map/block_map.rs                                |   26 
crates/editor/src/display_map/dimensions.rs                               |    4 
crates/editor/src/document_colors.rs                                      |    2 
crates/editor/src/document_symbols.rs                                     |  220 
crates/editor/src/editor.rs                                               |  644 
crates/editor/src/editor_tests.rs                                         |  294 
crates/editor/src/editor_tests/property_test.rs                           |   85 
crates/editor/src/element.rs                                              |   51 
crates/editor/src/folding_ranges.rs                                       |    2 
crates/editor/src/hover_links.rs                                          |   84 
crates/editor/src/hover_popover.rs                                        |  165 
crates/editor/src/inlays/inlay_hints.rs                                   |    7 
crates/editor/src/items.rs                                                |   15 
crates/editor/src/linked_editing_ranges.rs                                |    2 
crates/editor/src/movement.rs                                             |   20 
crates/editor/src/runnables.rs                                            | 1093 
crates/editor/src/semantic_tokens.rs                                      |    2 
crates/editor/src/split.rs                                                |   51 
crates/editor/src/tasks.rs                                                |  110 
crates/eval/src/eval.rs                                                   |    2 
crates/eval_cli/.gitignore                                                |    3 
crates/eval_cli/Cargo.toml                                                |   50 
crates/eval_cli/Dockerfile                                                |   62 
crates/eval_cli/Dockerfile.dockerignore                                   |   21 
crates/eval_cli/LICENSE-GPL                                               |    0 
crates/eval_cli/README.md                                                 |  108 
crates/eval_cli/build.rs                                                  |   15 
crates/eval_cli/script/build-linux                                        |   57 
crates/eval_cli/src/headless.rs                                           |  131 
crates/eval_cli/src/main.rs                                               |  546 
crates/eval_cli/zed_eval/__init__.py                                      |    3 
crates/eval_cli/zed_eval/agent.py                                         |  161 
crates/eval_cli/zed_eval/install.sh.j2                                    |   49 
crates/eval_cli/zed_eval/pyproject.toml                                   |   10 
crates/extension/src/extension.rs                                         |   12 
crates/extension/src/extension_builder.rs                                 |    3 
crates/extension_api/src/extension_api.rs                                 |   42 
crates/extension_api/wit/since_v0.8.0/extension.wit                       |   10 
crates/extension_cli/Cargo.toml                                           |    2 
crates/extension_cli/src/main.rs                                          |   61 
crates/extension_host/Cargo.toml                                          |    2 
crates/extension_host/src/extension_host.rs                               |   29 
crates/extension_host/src/headless_host.rs                                |    4 
crates/extension_host/src/wasm_host.rs                                    |   42 
crates/extension_host/src/wasm_host/wit.rs                                |   54 
crates/extensions_ui/src/extensions_ui.rs                                 |   35 
crates/feature_flags/Cargo.toml                                           |    1 
crates/feature_flags/src/feature_flags.rs                                 |   61 
crates/feedback/Cargo.toml                                                |    2 
crates/file_finder/Cargo.toml                                             |    5 
crates/file_finder/src/file_finder.rs                                     |  189 
crates/file_finder/src/file_finder_tests.rs                               |    4 
crates/fs/src/fake_git_repo.rs                                            |    2 
crates/fs/src/fs.rs                                                       |  129 
crates/fs/tests/integration/fs.rs                                         |   59 
crates/git/Cargo.toml                                                     |    1 
crates/git/src/blame.rs                                                   |    2 
crates/git/src/commit.rs                                                  |    4 
crates/git/src/git.rs                                                     |    3 
crates/git/src/repository.rs                                              |  115 
crates/git_graph/Cargo.toml                                               |    1 
crates/git_graph/src/git_graph.rs                                         |   44 
crates/git_ui/Cargo.toml                                                  |    1 
crates/git_ui/src/blame_ui.rs                                             |   18 
crates/git_ui/src/commit_modal.rs                                         |    9 
crates/git_ui/src/commit_tooltip.rs                                       |   13 
crates/git_ui/src/commit_view.rs                                          |    9 
crates/git_ui/src/conflict_view.rs                                        |  170 
crates/git_ui/src/file_diff_view.rs                                       |   10 
crates/git_ui/src/file_history_view.rs                                    |   14 
crates/git_ui/src/git_panel.rs                                            |    1 
crates/git_ui/src/git_ui.rs                                               |    4 
crates/git_ui/src/multi_diff_view.rs                                      |    8 
crates/git_ui/src/project_diff.rs                                         |  274 
crates/git_ui/src/text_diff_view.rs                                       |    2 
crates/go_to_line/Cargo.toml                                              |    2 
crates/go_to_line/src/go_to_line.rs                                       |    4 
crates/gpui/Cargo.toml                                                    |    4 
crates/gpui/examples/active_state_bug.rs                                  |   47 
crates/gpui/src/app.rs                                                    |   21 
crates/gpui/src/app/headless_app_context.rs                               |  275 
crates/gpui/src/app/test_app.rs                                           |  607 
crates/gpui/src/app/test_context.rs                                       |   30 
crates/gpui/src/color.rs                                                  |    9 
crates/gpui/src/elements/div.rs                                           |  104 
crates/gpui/src/elements/list.rs                                          |   36 
crates/gpui/src/elements/text.rs                                          |    7 
crates/gpui/src/executor.rs                                               |  157 
crates/gpui/src/gpui.rs                                                   |    7 
crates/gpui/src/interactive.rs                                            |   55 
crates/gpui/src/platform.rs                                               |   32 
crates/gpui/src/platform/test/dispatcher.rs                               |   15 
crates/gpui/src/platform/test/platform.rs                                 |   36 
crates/gpui/src/platform/test/window.rs                                   |   35 
crates/gpui/src/platform_scheduler.rs                                     |    5 
crates/gpui/src/scene.rs                                                  |    4 
crates/gpui/src/styled.rs                                                 |   14 
crates/gpui/src/test.rs                                                   |   33 
crates/gpui/src/text_system.rs                                            |  203 
crates/gpui/src/text_system/line.rs                                       |  405 
crates/gpui/src/text_system/line_layout.rs                                |  271 
crates/gpui/src/window.rs                                                 |  145 
crates/gpui_linux/src/linux/dispatcher.rs                                 |   10 
crates/gpui_linux/src/linux/wayland/client.rs                             |  124 
crates/gpui_linux/src/linux/wayland/window.rs                             |   54 
crates/gpui_linux/src/linux/x11/client.rs                                 |   13 
crates/gpui_linux/src/linux/x11/window.rs                                 |   63 
crates/gpui_macos/src/dispatcher.rs                                       |    9 
crates/gpui_macos/src/events.rs                                           |   25 
crates/gpui_macos/src/metal_renderer.rs                                   |  303 
crates/gpui_macos/src/text_system.rs                                      |    6 
crates/gpui_macos/src/window.rs                                           |   16 
crates/gpui_macros/Cargo.toml                                             |    2 
crates/gpui_macros/src/gpui_macros.rs                                     |   74 
crates/gpui_macros/src/property_test.rs                                   |  199 
crates/gpui_platform/src/gpui_platform.rs                                 |   16 
crates/gpui_web/src/dispatcher.rs                                         |   20 
crates/gpui_wgpu/src/gpui_wgpu.rs                                         |    3 
crates/gpui_wgpu/src/wgpu_atlas.rs                                        |   11 
crates/gpui_wgpu/src/wgpu_context.rs                                      |   29 
crates/gpui_wgpu/src/wgpu_renderer.rs                                     |  463 
crates/gpui_windows/src/dispatcher.rs                                     |    8 
crates/gpui_windows/src/events.rs                                         |  103 
crates/gpui_windows/src/window.rs                                         |    2 
crates/icons/src/icons.rs                                                 |    8 
crates/image_viewer/src/image_viewer.rs                                   |   64 
crates/journal/src/journal.rs                                             |    7 
crates/json_schema_store/src/json_schema_store.rs                         |   60 
crates/keymap_editor/src/keymap_editor.rs                                 |    8 
crates/language/Cargo.toml                                                |    1 
crates/language/src/buffer.rs                                             |   58 
crates/language/src/buffer_tests.rs                                       |   21 
crates/language/src/language.rs                                           |    9 
crates/language_extension/src/extension_lsp_adapter.rs                    |   38 
crates/language_model/src/language_model.rs                               |    7 
crates/language_model/src/model.rs                                        |    0 
crates/language_model/src/model/cloud_model.rs                            |   59 
crates/language_models/Cargo.toml                                         |    4 
crates/language_models/src/provider/bedrock.rs                            |    3 
crates/language_models/src/provider/cloud.rs                              |   23 
crates/language_models/src/provider/copilot_chat.rs                       |  194 
crates/language_models/src/provider/lmstudio.rs                           |  472 
crates/language_models/src/provider/ollama.rs                             |   38 
crates/language_models/src/provider/open_ai.rs                            |  220 
crates/language_models/src/provider/open_ai_compatible.rs                 |    4 
crates/language_onboarding/src/python.rs                                  |    4 
crates/language_tools/src/lsp_log_view.rs                                 |   35 
crates/languages/Cargo.toml                                               |    2 
crates/languages/src/gitcommit/config.toml                                |    2 
crates/languages/src/gomod/config.toml                                    |    2 
crates/languages/src/gowork/config.toml                                   |    2 
crates/languages/src/python.rs                                            |  256 
crates/languages/src/rust/injections.scm                                  |    2 
crates/languages/src/tsx/brackets.scm                                     |    9 
crates/livekit_client/Cargo.toml                                          |    1 
crates/livekit_client/src/lib.rs                                          |   24 
crates/livekit_client/src/livekit_client.rs                               |    5 
crates/livekit_client/src/livekit_client/playback.rs                      |   54 
crates/livekit_client/src/record.rs                                       |    7 
crates/lmstudio/src/lmstudio.rs                                           |   21 
crates/markdown_preview/src/markdown_preview_view.rs                      |    5 
crates/multi_buffer/Cargo.toml                                            |    1 
crates/multi_buffer/src/multi_buffer.rs                                   |   22 
crates/multi_buffer/src/multi_buffer_tests.rs                             |    5 
crates/notifications/Cargo.toml                                           |    4 
crates/onboarding/src/basics_page.rs                                      |    8 
crates/onboarding/src/multibuffer_hint.rs                                 |    9 
crates/open_ai/src/open_ai.rs                                             |   27 
crates/open_ai/src/responses.rs                                           |   68 
crates/open_path_prompt/src/file_finder_settings.rs                       |    2 
crates/outline/Cargo.toml                                                 |    2 
crates/outline_panel/src/outline_panel.rs                                 |    2 
crates/panel/src/panel.rs                                                 |    1 
crates/platform_title_bar/src/platform_title_bar.rs                       |   50 
crates/project/Cargo.toml                                                 |    3 
crates/project/src/agent_registry_store.rs                                |   16 
crates/project/src/agent_server_store.rs                                  |   98 
crates/project/src/buffer_store.rs                                        |    5 
crates/project/src/context_server_store.rs                                |   31 
crates/project/src/debugger/session.rs                                    |   28 
crates/project/src/git_store.rs                                           |  125 
crates/project/src/image_store.rs                                         |    5 
crates/project/src/lsp_store.rs                                           |   47 
crates/project/src/lsp_store/json_language_server_ext.rs                  |   24 
crates/project/src/lsp_store/semantic_tokens.rs                           |   11 
crates/project/src/project.rs                                             |    4 
crates/project/tests/integration/context_server_store.rs                  |  113 
crates/project/tests/integration/ext_agent_tests.rs                       |    1 
crates/project/tests/integration/extension_agent_tests.rs                 |    1 
crates/project/tests/integration/project_tests.rs                         |   81 
crates/project_panel/Cargo.toml                                           |    1 
crates/project_panel/src/project_panel.rs                                 |   81 
crates/project_panel/src/project_panel_settings.rs                        |   13 
crates/project_panel/src/project_panel_tests.rs                           |  133 
crates/proto/Cargo.toml                                                   |    4 
crates/proto/proto/ai.proto                                               |    2 
crates/proto/proto/git.proto                                              |   15 
crates/proto/proto/zed.proto                                              |    4 
crates/proto/src/proto.rs                                                 |    6 
crates/recent_projects/Cargo.toml                                         |    1 
crates/recent_projects/src/disconnected_overlay.rs                        |    9 
crates/recent_projects/src/recent_projects.rs                             |   25 
crates/recent_projects/src/remote_servers.rs                              |   12 
crates/remote_server/Cargo.toml                                           |    3 
crates/remote_server/src/remote_editing_tests.rs                          |    1 
crates/repl/Cargo.toml                                                    |    1 
crates/repl/src/components/kernel_options.rs                              |   34 
crates/repl/src/kernels/mod.rs                                            |   37 
crates/repl/src/kernels/native_kernel.rs                                  |   27 
crates/repl/src/kernels/wsl_kernel.rs                                     |   30 
crates/repl/src/notebook/notebook_ui.rs                                   |    9 
crates/repl/src/repl.rs                                                   |    8 
crates/repl/src/repl_editor.rs                                            |    1 
crates/repl/src/repl_sessions_ui.rs                                       |    3 
crates/repl/src/repl_store.rs                                             |   28 
crates/reqwest_client/Cargo.toml                                          |    1 
crates/rich_text/Cargo.toml                                               |   29 
crates/rich_text/src/rich_text.rs                                         |  418 
crates/rope/src/rope.rs                                                   |   18 
crates/rules_library/src/rules_library.rs                                 |   83 
crates/scheduler/src/executor.rs                                          |   45 
crates/scheduler/src/scheduler.rs                                         |   16 
crates/scheduler/src/test_scheduler.rs                                    |    4 
crates/search/Cargo.toml                                                  |    3 
crates/search/src/project_search.rs                                       |   20 
crates/settings/src/vscode_import.rs                                      |    7 
crates/settings_content/src/agent.rs                                      |   21 
crates/settings_content/src/language_model.rs                             |    1 
crates/settings_content/src/project.rs                                    |   26 
crates/settings_content/src/settings_content.rs                           |    6 
crates/settings_content/src/workspace.rs                                  |   23 
crates/settings_profile_selector/Cargo.toml                               |    2 
crates/settings_ui/Cargo.toml                                             |    7 
crates/settings_ui/src/page_data.rs                                       |   52 
crates/settings_ui/src/pages/tool_permissions_setup.rs                    |   17 
crates/settings_ui/src/settings_ui.rs                                     |   31 
crates/sidebar/Cargo.toml                                                 |   50 
crates/sidebar/LICENSE-GPL                                                |    1 
crates/sidebar/src/sidebar.rs                                             | 3136 
crates/sqlez/src/connection.rs                                            |  103 
crates/sqlez/src/thread_safe_connection.rs                                |  101 
crates/sum_tree/Cargo.toml                                                |    6 
crates/sum_tree/src/property_test.rs                                      |   32 
crates/sum_tree/src/sum_tree.rs                                           |    2 
crates/svg_preview/src/svg_preview_view.rs                                |    2 
crates/tab_switcher/Cargo.toml                                            |    2 
crates/task/src/task_template.rs                                          |    1 
crates/tasks_ui/src/tasks_ui.rs                                           |    4 
crates/terminal/Cargo.toml                                                |    1 
crates/terminal/src/terminal.rs                                           |   15 
crates/terminal_view/Cargo.toml                                           |    3 
crates/terminal_view/src/terminal_panel.rs                                |  147 
crates/terminal_view/src/terminal_view.rs                                 |  464 
crates/text/Cargo.toml                                                    |    1 
crates/text/src/anchor.rs                                                 |    4 
crates/text/src/text.rs                                                   |   13 
crates/theme/src/settings.rs                                              |   12 
crates/theme_selector/src/icon_theme_selector.rs                          |    9 
crates/theme_selector/src/theme_selector.rs                               |    9 
crates/title_bar/Cargo.toml                                               |    9 
crates/title_bar/src/plan_chip.rs                                         |    1 
crates/title_bar/src/title_bar.rs                                         |   99 
crates/ui/src/components.rs                                               |    2 
crates/ui/src/components/ai.rs                                            |    2 
crates/ui/src/components/ai/configured_api_card.rs                        |   64 
crates/ui/src/components/ai/copilot_configuration_callout.rs              |    1 
crates/ui/src/components/ai/thread_item.rs                                |  310 
crates/ui/src/components/ai/thread_sidebar_toggle.rs                      |  177 
crates/ui/src/components/banner.rs                                        |   10 
crates/ui/src/components/button.rs                                        |    1 
crates/ui/src/components/button/button.rs                                 |  226 
crates/ui/src/components/button/button_icon.rs                            |  199 
crates/ui/src/components/button/icon_button.rs                            |   49 
crates/ui/src/components/chip.rs                                          |    3 
crates/ui/src/components/data_table.rs                                    |  540 
crates/ui/src/components/data_table/table_row.rs                          |  208 
crates/ui/src/components/data_table/tests.rs                              |  318 
crates/ui/src/components/diff_stat.rs                                     |   42 
crates/ui/src/components/dropdown_menu.rs                                 |    9 
crates/ui/src/components/gradient_fade.rs                                 |   88 
crates/ui/src/components/label/label.rs                                   |   28 
crates/ui/src/components/list/list_item.rs                                |   72 
crates/ui/src/components/scrollbar.rs                                     |   13 
crates/util/Cargo.toml                                                    |    1 
crates/util/src/paths.rs                                                  |   19 
crates/vim/Cargo.toml                                                     |    2 
crates/vim/src/helix.rs                                                   |  119 
crates/vim/src/state.rs                                                   |    6 
crates/vim/src/vim.rs                                                     |   15 
crates/watch/Cargo.toml                                                   |    1 
crates/web_search_providers/src/cloud.rs                                  |   28 
crates/workspace/Cargo.toml                                               |    1 
crates/workspace/src/item.rs                                              |   53 
crates/workspace/src/multi_workspace.rs                                   |  405 
crates/workspace/src/notifications.rs                                     |   42 
crates/workspace/src/pane.rs                                              |  222 
crates/workspace/src/persistence.rs                                       |   26 
crates/workspace/src/persistence/model.rs                                 |    9 
crates/workspace/src/status_bar.rs                                        |   16 
crates/workspace/src/welcome.rs                                           |   51 
crates/workspace/src/workspace.rs                                         |  243 
crates/worktree/Cargo.toml                                                |    4 
crates/worktree/src/worktree.rs                                           |   10 
crates/zed/Cargo.toml                                                     |    6 
crates/zed/build.rs                                                       |   26 
crates/zed/src/main.rs                                                    |   38 
crates/zed/src/visual_test_runner.rs                                      |   44 
crates/zed/src/zed.rs                                                     |  201 
crates/zed/src/zed/edit_prediction_registry.rs                            |    2 
crates/zed/src/zed/open_listener.rs                                       |  155 
crates/zed_actions/src/lib.rs                                             |   33 
crates/zeta_prompt/src/excerpt_ranges.rs                                  |  443 
crates/zeta_prompt/src/multi_region.rs                                    |  557 
crates/zeta_prompt/src/zeta_prompt.rs                                     | 1625 
docs/AGENTS.md                                                            |   68 
docs/src/SUMMARY.md                                                       |    1 
docs/src/ai/agent-settings.md                                             |    2 
docs/src/ai/privacy-and-security.md                                       |    2 
docs/src/appearance.md                                                    |    8 
docs/src/development/feature-process.md                                   |   55 
docs/src/development/glossary.md                                          |    2 
docs/src/development/macos.md                                             |    2 
docs/src/extensions.md                                                    |    1 
docs/src/extensions/developing-extensions.md                              |    8 
docs/src/extensions/languages.md                                          |    2 
docs/src/extensions/snippets.md                                           |   27 
docs/src/languages/python.md                                              |    2 
docs/src/languages/vue.md                                                 |   54 
docs/src/reference/all-settings.md                                        |   35 
docs/src/themes.md                                                        |   29 
docs/theme/css/chrome.css                                                 |   19 
docs/theme/index.hbs                                                      |   25 
extensions/glsl/languages/glsl/config.toml                                |    2 
extensions/html/languages/html/brackets.scm                               |    4 
nix/build.nix                                                             |    7 
nix/modules/devshells.nix                                                 |   14 
nix/toolchain.nix                                                         |    1 
script/danger/dangerfile.ts                                               |   19 
script/linux                                                              |   17 
tooling/xtask/src/tasks/workflows.rs                                      |   85 
tooling/xtask/src/tasks/workflows/deploy_collab.rs                        |   10 
tooling/xtask/src/tasks/workflows/extension_auto_bump.rs                  |  113 
tooling/xtask/src/tasks/workflows/extension_bump.rs                       |   87 
tooling/xtask/src/tasks/workflows/extension_tests.rs                      |   97 
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs           |  264 
tooling/xtask/src/tasks/workflows/extensions/bump_version.rs              |    8 
tooling/xtask/src/tasks/workflows/extensions/run_tests.rs                 |    9 
tooling/xtask/src/tasks/workflows/run_tests.rs                            |  124 
tooling/xtask/src/tasks/workflows/steps.rs                                |   29 
tooling/xtask/src/tasks/workflows/vars.rs                                 |   33 
typos.toml                                                                |    4 
523 files changed, 31,380 insertions(+), 12,791 deletions(-)

Detailed changes

.github/DISCUSSION_TEMPLATE/feature-requests.yml 🔗

@@ -40,4 +40,4 @@ body:
     attributes:
       value: |
         Learn more about how feature requests work in our
-        [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/47963).
+        [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/51422).

.github/workflows/assign-reviewers.yml 🔗

@@ -0,0 +1,81 @@
+# Assign Reviewers — Smart team assignment based on diff weight
+#
+# Triggers on PR open and ready_for_review events. Checks out the coordinator
+# repo (zed-industries/codeowner-coordinator) to access the assignment script and rules,
+# then assigns the 1-2 most relevant teams as reviewers.
+#
+# NOTE: This file is stored in the codeowner-coordinator repo but must be deployed to
+# the zed repo at .github/workflows/assign-reviewers.yml. See INSTALL.md.
+#
+# AUTH NOTE: Uses a GitHub App (COORDINATOR_APP_ID + COORDINATOR_APP_PRIVATE_KEY)
+# for all API operations: cloning the private coordinator repo, requesting team
+# reviewers, and setting PR assignees. GITHUB_TOKEN is not used.
+
+name: Assign Reviewers
+
+on:
+  pull_request:
+    types: [opened, ready_for_review]
+
+# GITHUB_TOKEN is not used — all operations use the GitHub App token.
+# Declare minimal permissions so the default token has no write access.
+permissions: {}
+
+# Only run for PRs from within the org (not forks) — fork PRs don't have
+# write access to request team reviewers.
+jobs:
+  assign-reviewers:
+    if: >-
+      github.event.pull_request.head.repo.full_name == github.repository &&
+      github.event.pull_request.draft == false &&
+      contains(fromJSON('["MEMBER", "OWNER"]'), github.event.pull_request.author_association)
+    runs-on: ubuntu-latest
+    steps:
+      - name: Generate app token
+        id: app-token
+        uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf  # v2.2.1
+        with:
+          app-id: ${{ vars.COORDINATOR_APP_ID }}
+          private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }}
+          repositories: codeowner-coordinator,zed
+
+      - name: Checkout coordinator repo
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5  # v4.3.1
+        with:
+          repository: zed-industries/codeowner-coordinator
+          ref: main
+          path: codeowner-coordinator
+          token: ${{ steps.app-token.outputs.token }}
+          persist-credentials: false
+
+      - name: Setup Python
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
+        with:
+          python-version: "3.11"
+
+      - name: Install dependencies
+        run: pip install pyyaml==6.0.3
+
+      - name: Assign reviewers
+        env:
+          GH_TOKEN: ${{ steps.app-token.outputs.token }}
+          PR_URL: ${{ github.event.pull_request.html_url }}
+          TARGET_REPO: ${{ github.repository }}
+        run: |
+          cd codeowner-coordinator
+          python .github/scripts/assign-reviewers.py \
+            --pr "$PR_URL" \
+            --apply \
+            --rules-file team-membership-rules.yml \
+            --repo "$TARGET_REPO" \
+            --org zed-industries \
+            --min-association member \
+            2>&1 | tee /tmp/assign-reviewers-output.txt
+
+      - name: Upload output
+        if: always()
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
+        with:
+          name: assign-reviewers-output
+          path: /tmp/assign-reviewers-output.txt
+          retention-days: 30

.github/workflows/bump_patch_version.yml 🔗

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

.github/workflows/deploy_collab.yml 🔗

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

.github/workflows/extension_auto_bump.yml 🔗

@@ -0,0 +1,72 @@
+# Generated from xtask::workflows::extension_auto_bump
+# Rebuild with `cargo xtask workflows`.
+name: extension_auto_bump
+on:
+  push:
+    branches:
+    - main
+    paths:
+    - extensions/**
+    - '!extensions/workflows/**'
+    - '!extensions/*.md'
+jobs:
+  detect_changed_extensions:
+    if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    steps:
+    - name: steps::checkout_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+        fetch-depth: 2
+    - id: detect
+      name: extension_auto_bump::detect_changed_extensions
+      run: |
+        COMPARE_REV="$(git rev-parse HEAD~1)"
+        CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
+        # Detect changed extension directories (excluding extensions/workflows)
+        CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true)
+        if [ -n "$CHANGED_EXTENSIONS" ]; then
+            EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
+        else
+            EXTENSIONS_JSON="[]"
+        fi
+        # Filter out newly added or entirely removed extensions
+        FILTERED="[]"
+        for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do
+            if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \
+               [ -f "$ext/extension.toml" ]; then
+                FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]')
+            fi
+        done
+        echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT"
+    outputs:
+      changed_extensions: ${{ steps.detect.outputs.changed_extensions }}
+    timeout-minutes: 5
+  bump_extension_versions:
+    needs:
+    - detect_changed_extensions
+    if: needs.detect_changed_extensions.outputs.changed_extensions != '[]'
+    permissions:
+      actions: write
+      contents: write
+      issues: write
+      pull-requests: write
+    strategy:
+      matrix:
+        extension: ${{ fromJson(needs.detect_changed_extensions.outputs.changed_extensions) }}
+      fail-fast: false
+      max-parallel: 1
+    uses: ./.github/workflows/extension_bump.yml
+    secrets:
+      app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+      app-secret: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    with:
+      working-directory: ${{ matrix.extension }}
+      force-bump: false
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  cancel-in-progress: true
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}

.github/workflows/extension_bump.yml 🔗

@@ -17,6 +17,10 @@ on:
         description: force-bump
         required: true
         type: boolean
+      working-directory:
+        description: working-directory
+        type: string
+        default: .
     secrets:
       app-id:
         description: The app ID used to create the PR
@@ -42,8 +46,6 @@ jobs:
         if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
             PR_FORK_POINT="$(git merge-base origin/main HEAD)"
             git checkout "$PR_FORK_POINT"
-        elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
-            git checkout "$BRANCH_PARENT_SHA"
         else
             git checkout "$(git log -1 --format=%H)"~1
         fi
@@ -59,6 +61,10 @@ jobs:
       version_changed: ${{ steps.compare-versions-check.outputs.version_changed }}
       current_version: ${{ steps.compare-versions-check.outputs.current_version }}
     timeout-minutes: 1
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   bump_extension_version:
     needs:
     - check_version_changed
@@ -98,18 +104,35 @@ jobs:
         fi
 
         NEW_VERSION="$(sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]')"
+        EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+        EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+
+        if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
+            {
+                echo "title=Bump version to ${NEW_VERSION}";
+                echo "body=This PR bumps the version of this extension to v${NEW_VERSION}";
+                echo "branch_name=zed-zippy-autobump";
+            } >> "$GITHUB_OUTPUT"
+        else
+            {
+                echo "title=${EXTENSION_ID}: Bump to v${NEW_VERSION}";
+                echo "body=This PR bumps the version of the ${EXTENSION_NAME} extension to v${NEW_VERSION}";
+                echo "branch_name=zed-zippy-${EXTENSION_ID}-autobump";
+            } >> "$GITHUB_OUTPUT"
+        fi
 
         echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT"
       env:
         OLD_VERSION: ${{ needs.check_version_changed.outputs.current_version }}
         BUMP_TYPE: ${{ inputs.bump-type }}
+        WORKING_DIR: ${{ inputs.working-directory }}
     - name: extension_bump::create_pull_request
       uses: peter-evans/create-pull-request@v7
       with:
-        title: Bump version to ${{ steps.bump-version.outputs.new_version }}
-        body: This PR bumps the version of this extension to v${{ steps.bump-version.outputs.new_version }}
-        commit-message: Bump version to v${{ steps.bump-version.outputs.new_version }}
-        branch: zed-zippy-autobump
+        title: ${{ steps.bump-version.outputs.title }}
+        body: ${{ steps.bump-version.outputs.body }}
+        commit-message: ${{ steps.bump-version.outputs.title }}
+        branch: ${{ steps.bump-version.outputs.branch_name }}
         committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
         base: main
         delete-branch: true
@@ -117,6 +140,10 @@ jobs:
         sign-commits: true
         assignees: ${{ github.actor }}
     timeout-minutes: 3
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   create_version_label:
     needs:
     - check_version_changed
@@ -145,6 +172,10 @@ jobs:
           })
         github-token: ${{ steps.generate-token.outputs.token }}
     timeout-minutes: 1
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   trigger_release:
     needs:
     - check_version_changed
@@ -178,8 +209,12 @@ jobs:
         tag: v${{ needs.check_version_changed.outputs.current_version }}
       env:
         COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }}
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}extension-bump
   cancel-in-progress: true
 defaults:
   run:

.github/workflows/extension_tests.yml 🔗

@@ -9,7 +9,12 @@ env:
   RUSTUP_TOOLCHAIN: stable
   CARGO_BUILD_TARGET: wasm32-wasip2
 on:
-  workflow_call: {}
+  workflow_call:
+    inputs:
+      working-directory:
+        description: working-directory
+        type: string
+        default: .
 jobs:
   orchestrate:
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
@@ -34,6 +39,14 @@ jobs:
         fi
         CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
 
+        # When running from a subdirectory, git diff returns repo-root-relative paths.
+        # Filter to only files within the current working directory and strip the prefix.
+        REPO_SUBDIR="$(git rev-parse --show-prefix)"
+        REPO_SUBDIR="${REPO_SUBDIR%/}"
+        if [ -n "$REPO_SUBDIR" ]; then
+            CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)"
+        fi
+
         check_pattern() {
           local output_name="$1"
           local pattern="$2"
@@ -49,6 +62,10 @@ jobs:
     outputs:
       check_rust: ${{ steps.filter.outputs.check_rust }}
       check_extension: ${{ steps.filter.outputs.check_extension }}
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   check_rust:
     needs:
     - orchestrate
@@ -66,17 +83,31 @@ jobs:
         path: ~/.rustup
     - name: extension_tests::install_rust_target
       run: rustup target add wasm32-wasip2
-    - name: steps::cargo_fmt
-      run: cargo fmt --all -- --check
+    - id: get-package-name
+      name: extension_tests::get_package_name
+      run: |
+        PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')"
+        echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
+    - name: extension_tests::cargo_fmt_package
+      run: cargo fmt -p "$PACKAGE_NAME" -- --check
+      env:
+        PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }}
     - name: extension_tests::run_clippy
-      run: cargo clippy --release --all-features -- --deny warnings
+      run: cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings
+      env:
+        PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }}
     - name: steps::cargo_install_nextest
       uses: taiki-e/install-action@nextest
-    - name: steps::cargo_nextest
-      run: 'cargo nextest run --workspace --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"'
+    - name: extension_tests::run_nextest
+      run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"'
       env:
+        PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }}
         NEXTEST_NO_TESTS: warn
     timeout-minutes: 6
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   check_extension:
     needs:
     - orchestrate
@@ -97,8 +128,8 @@ jobs:
     - name: extension_tests::download_zed_extension_cli
       if: steps.cache-zed-extension-cli.outputs.cache-hit != 'true'
       run: |
-        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension"
-        chmod +x zed-extension
+        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension"
+        chmod +x "$GITHUB_WORKSPACE/zed-extension"
     - name: steps::cache_rust_dependencies_namespace
       uses: namespacelabs/nscloud-cache-action@v1
       with:
@@ -108,7 +139,7 @@ jobs:
       run: |
         mkdir -p /tmp/ext-scratch
         mkdir -p /tmp/ext-output
-        ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
+        "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
     - name: run_tests::fetch_ts_query_ls
       uses: dsaltares/fetch-gh-release-asset@aa37ae5c44d3c9820bc12fe675e8670ecd93bd1c
       with:
@@ -117,8 +148,8 @@ jobs:
         file: ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
     - name: run_tests::run_ts_query_ls
       run: |-
-        tar -xf ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
-        ./ts_query_ls format --check . || {
+        tar -xf "$GITHUB_WORKSPACE/ts_query_ls-x86_64-unknown-linux-gnu.tar.gz" -C "$GITHUB_WORKSPACE"
+        "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {
             echo "Found unformatted queries, please format them with ts_query_ls."
             echo "For easy use, install the Tree-sitter query extension:"
             echo "zed://extension/tree-sitter-query"
@@ -132,8 +163,6 @@ jobs:
         if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
             PR_FORK_POINT="$(git merge-base origin/main HEAD)"
             git checkout "$PR_FORK_POINT"
-        elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
-            git checkout "$BRANCH_PARENT_SHA"
         else
             git checkout "$(git log -1 --format=%H)"~1
         fi
@@ -156,6 +185,10 @@ jobs:
         VERSION_CHANGED: ${{ steps.compare-versions-check.outputs.version_changed }}
         PR_USER_LOGIN: ${{ github.event.pull_request.user.login }}
     timeout-minutes: 6
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   tests_pass:
     needs:
     - orchestrate
@@ -184,7 +217,7 @@ jobs:
         RESULT_CHECK_RUST: ${{ needs.check_rust.result }}
         RESULT_CHECK_EXTENSION: ${{ needs.check_extension.result }}
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}extension-tests
   cancel-in-progress: true
 defaults:
   run:

.github/workflows/extension_workflow_rollout.yml 🔗

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

.github/workflows/run_tests.yml 🔗

@@ -103,13 +103,22 @@ jobs:
         check_pattern "run_action_checks" '^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/' -qP
         check_pattern "run_docs" '^(docs/|crates/.*\.rs)' -qP
         check_pattern "run_licenses" '^(Cargo.lock|script/.*licenses)' -qP
-        check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))' -qvP
+        check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)' -qvP
+        # Detect changed extension directories (excluding extensions/workflows)
+        CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true)
+        if [ -n "$CHANGED_EXTENSIONS" ]; then
+            EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
+        else
+            EXTENSIONS_JSON="[]"
+        fi
+        echo "changed_extensions=$EXTENSIONS_JSON" >> "$GITHUB_OUTPUT"
     outputs:
       changed_packages: ${{ steps.filter.outputs.changed_packages }}
       run_action_checks: ${{ steps.filter.outputs.run_action_checks }}
       run_docs: ${{ steps.filter.outputs.run_docs }}
       run_licenses: ${{ steps.filter.outputs.run_licenses }}
       run_tests: ${{ steps.filter.outputs.run_tests }}
+      changed_extensions: ${{ steps.filter.outputs.changed_extensions }}
   check_style:
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
     runs-on: namespace-profile-4x8-ubuntu-2204
@@ -147,8 +156,8 @@ jobs:
         file: ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
     - name: run_tests::run_ts_query_ls
       run: |-
-        tar -xf ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
-        ./ts_query_ls format --check . || {
+        tar -xf "$GITHUB_WORKSPACE/ts_query_ls-x86_64-unknown-linux-gnu.tar.gz" -C "$GITHUB_WORKSPACE"
+        "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {
             echo "Found unformatted queries, please format them with ts_query_ls."
             echo "For easy use, install the Tree-sitter query extension:"
             echo "zed://extension/tree-sitter-query"
@@ -711,6 +720,20 @@ jobs:
     - name: run_tests::check_postgres_and_protobuf_migrations::check_protobuf_formatting
       run: buf format --diff --exit-code crates/proto/proto
     timeout-minutes: 60
+  extension_tests:
+    needs:
+    - orchestrate
+    if: needs.orchestrate.outputs.changed_extensions != '[]'
+    permissions:
+      contents: read
+    strategy:
+      matrix:
+        extension: ${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}
+      fail-fast: false
+      max-parallel: 1
+    uses: ./.github/workflows/extension_tests.yml
+    with:
+      working-directory: ${{ matrix.extension }}
   tests_pass:
     needs:
     - orchestrate
@@ -728,6 +751,7 @@ jobs:
     - check_docs
     - check_licenses
     - check_scripts
+    - extension_tests
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && always()
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
@@ -756,6 +780,7 @@ jobs:
         check_result "check_docs" "$RESULT_CHECK_DOCS"
         check_result "check_licenses" "$RESULT_CHECK_LICENSES"
         check_result "check_scripts" "$RESULT_CHECK_SCRIPTS"
+        check_result "extension_tests" "$RESULT_EXTENSION_TESTS"
 
         exit $EXIT_CODE
       env:
@@ -774,6 +799,7 @@ jobs:
         RESULT_CHECK_DOCS: ${{ needs.check_docs.result }}
         RESULT_CHECK_LICENSES: ${{ needs.check_licenses.result }}
         RESULT_CHECK_SCRIPTS: ${{ needs.check_scripts.result }}
+        RESULT_EXTENSION_TESTS: ${{ needs.extension_tests.result }}
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
   cancel-in-progress: true

CONTRIBUTING.md 🔗

@@ -26,6 +26,8 @@ If you're looking for concrete ideas:
 - [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible).
 - [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search).
 
+If you're thinking about proposing or building a larger feature, read the [Zed Feature Process](./docs/src/development/feature-process.md) for how we think about feature design — what context to provide, what integration points to consider, and how to put together a strong proposal.
+
 ## Sending changes
 
 The Zed culture values working code and synchronous conversations over long

Cargo.lock 🔗

@@ -36,7 +36,6 @@ dependencies = [
  "smol",
  "task",
  "telemetry",
- "tempfile",
  "terminal",
  "text",
  "ui",
@@ -45,7 +44,6 @@ dependencies = [
  "util",
  "uuid",
  "watch",
- "zlog",
 ]
 
 [[package]]
@@ -79,7 +77,6 @@ dependencies = [
  "fs",
  "futures 0.3.31",
  "gpui",
- "indoc",
  "language",
  "log",
  "pretty_assertions",
@@ -108,7 +105,6 @@ dependencies = [
  "language",
  "project",
  "proto",
- "release_channel",
  "smallvec",
  "ui",
  "util",
@@ -214,11 +210,9 @@ dependencies = [
  "task",
  "telemetry",
  "tempfile",
- "terminal",
  "text",
  "theme",
  "thiserror 2.0.17",
- "tree-sitter-rust",
  "ui",
  "unindent",
  "url",
@@ -226,7 +220,6 @@ dependencies = [
  "uuid",
  "watch",
  "web_search",
- "worktree",
  "zed_env_vars",
  "zlog",
  "zstd",
@@ -234,9 +227,9 @@ dependencies = [
 
 [[package]]
 name = "agent-client-protocol"
-version = "0.9.4"
+version = "0.10.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475"
+checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2"
 dependencies = [
  "agent-client-protocol-schema",
  "anyhow",
@@ -251,16 +244,16 @@ dependencies = [
 
 [[package]]
 name = "agent-client-protocol-schema"
-version = "0.10.8"
+version = "0.11.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1"
+checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789"
 dependencies = [
  "anyhow",
  "derive_more",
  "schemars",
  "serde",
  "serde_json",
- "strum 0.27.2",
+ "strum 0.28.0",
 ]
 
 [[package]]
@@ -285,7 +278,6 @@ dependencies = [
  "gpui_tokio",
  "http_client",
  "indoc",
- "language",
  "language_model",
  "libc",
  "log",
@@ -319,7 +311,6 @@ dependencies = [
  "gpui",
  "language_model",
  "log",
- "paths",
  "project",
  "regex",
  "schemars",
@@ -352,7 +343,6 @@ dependencies = [
  "buffer_diff",
  "chrono",
  "client",
- "clock",
  "cloud_api_types",
  "cloud_llm_client",
  "collections",
@@ -398,9 +388,7 @@ dependencies = [
  "prompt_store",
  "proto",
  "rand 0.9.2",
- "recent_projects",
  "release_channel",
- "remote_connection",
  "reqwest_client",
  "rope",
  "rules_library",
@@ -415,14 +403,12 @@ dependencies = [
  "streaming_diff",
  "task",
  "telemetry",
- "tempfile",
  "terminal",
  "terminal_view",
  "text",
  "theme",
  "time",
  "time_format",
- "title_bar",
  "tree-sitter-md",
  "ui",
  "ui_input",
@@ -671,17 +657,13 @@ dependencies = [
  "anyhow",
  "chrono",
  "futures 0.3.31",
- "gpui",
- "gpui_tokio",
  "http_client",
- "reqwest_client",
  "schemars",
  "serde",
  "serde_json",
  "settings",
  "strum 0.27.2",
  "thiserror 2.0.17",
- "tokio",
 ]
 
 [[package]]
@@ -731,7 +713,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -893,7 +875,6 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "gpui",
- "indoc",
  "itertools 0.14.0",
  "language",
  "language_model",
@@ -1128,7 +1109,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -1196,7 +1177,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -1226,7 +1207,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2065,7 +2046,7 @@ dependencies = [
  "regex",
  "rustc-hash 2.1.1",
  "shlex",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2083,7 +2064,7 @@ dependencies = [
  "regex",
  "rustc-hash 2.1.1",
  "shlex",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2212,13 +2193,13 @@ version = "3.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365"
 dependencies = [
- "darling",
+ "darling 0.20.11",
  "ident_case",
  "prettyplease",
  "proc-macro2",
  "quote",
  "rustversion",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2247,7 +2228,7 @@ dependencies = [
  "proc-macro-crate",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2320,7 +2301,6 @@ dependencies = [
  "pretty_assertions",
  "rand 0.9.2",
  "rope",
- "serde_json",
  "settings",
  "sum_tree",
  "text",
@@ -2397,7 +2377,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2479,10 +2459,10 @@ version = "0.25.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201"
 dependencies = [
- "darling",
+ "darling 0.20.11",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2504,7 +2484,6 @@ dependencies = [
  "futures 0.3.31",
  "gpui",
  "gpui_tokio",
- "http_client",
  "language",
  "livekit_client",
  "log",
@@ -2736,7 +2715,7 @@ dependencies = [
  "quote",
  "serde",
  "serde_json",
- "syn 2.0.106",
+ "syn 2.0.117",
  "tempfile",
  "toml 0.8.23",
 ]
@@ -2965,7 +2944,7 @@ dependencies = [
  "heck 0.5.0",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3099,8 +3078,6 @@ name = "cloud_llm_client"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "indoc",
- "pretty_assertions",
  "serde",
  "serde_json",
  "strum 0.27.2",
@@ -3225,6 +3202,7 @@ dependencies = [
  "serde",
  "serde_json",
  "text",
+ "zeta_prompt",
 ]
 
 [[package]]
@@ -3232,15 +3210,11 @@ name = "collab"
 version = "0.44.0"
 dependencies = [
  "agent",
- "agent-client-protocol",
- "agent_settings",
- "agent_ui",
  "anyhow",
  "assistant_slash_command",
  "assistant_text_thread",
  "async-trait",
  "async-tungstenite",
- "audio",
  "aws-config",
  "aws-sdk-kinesis",
  "aws-sdk-s3",
@@ -3256,10 +3230,8 @@ dependencies = [
  "collab_ui",
  "collections",
  "command_palette_hooks",
- "context_server",
  "ctor",
  "dap",
- "dap-types",
  "dap_adapters",
  "dashmap",
  "debugger_ui",
@@ -3276,7 +3248,6 @@ dependencies = [
  "gpui_tokio",
  "hex",
  "http_client",
- "hyper 0.14.32",
  "indoc",
  "language",
  "language_model",
@@ -3318,7 +3289,6 @@ dependencies = [
  "text",
  "theme",
  "time",
- "title_bar",
  "tokio",
  "toml 0.8.23",
  "tower 0.4.13",
@@ -3349,12 +3319,10 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "gpui",
- "http_client",
  "log",
  "menu",
  "notifications",
  "picker",
- "pretty_assertions",
  "project",
  "release_channel",
  "rpc",
@@ -3367,7 +3335,6 @@ dependencies = [
  "time",
  "time_format",
  "title_bar",
- "tree-sitter-md",
  "ui",
  "util",
  "workspace",
@@ -3421,10 +3388,8 @@ dependencies = [
  "client",
  "collections",
  "command_palette_hooks",
- "ctor",
  "db",
  "editor",
- "env_logger 0.11.8",
  "fuzzy",
  "go_to_line",
  "gpui",
@@ -3435,7 +3400,6 @@ dependencies = [
  "postage",
  "project",
  "serde",
- "serde_json",
  "settings",
  "telemetry",
  "theme",
@@ -3643,24 +3607,29 @@ dependencies = [
  "unicode-segmentation",
 ]
 
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
 [[package]]
 name = "copilot"
 version = "0.1.0"
 dependencies = [
  "anyhow",
  "async-std",
- "client",
- "clock",
  "collections",
  "command_palette_hooks",
  "copilot_chat",
- "ctor",
  "edit_prediction_types",
  "editor",
  "fs",
  "futures 0.3.31",
  "gpui",
- "http_client",
  "icons",
  "indoc",
  "language",
@@ -3687,6 +3656,7 @@ dependencies = [
 name = "copilot_chat"
 version = "0.1.0"
 dependencies = [
+ "anthropic",
  "anyhow",
  "collections",
  "dirs 4.0.0",
@@ -4354,7 +4324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
 dependencies = [
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4431,7 +4401,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "scratch",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4445,7 +4415,7 @@ dependencies = [
  "indexmap",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4463,7 +4433,7 @@ dependencies = [
  "indexmap",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4497,8 +4467,6 @@ dependencies = [
  "smol",
  "task",
  "telemetry",
- "tree-sitter",
- "tree-sitter-go",
  "util",
  "zlog",
 ]
@@ -4545,8 +4513,18 @@ version = "0.20.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
 dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
 ]
 
 [[package]]
@@ -4560,7 +4538,21 @@ dependencies = [
  "proc-macro2",
  "quote",
  "strsim",
- "syn 2.0.106",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4569,9 +4561,20 @@ version = "0.20.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
 dependencies = [
- "darling_core",
+ "darling_core 0.20.11",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core 0.21.3",
+ "quote",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4803,7 +4806,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4825,7 +4828,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "rustc_version",
- "syn 2.0.106",
+ "syn 2.0.117",
  "unicode-xid",
 ]
 
@@ -4835,19 +4838,19 @@ version = "0.1.0"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
 name = "derive_setters"
-version = "0.1.8"
+version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae5c625eda104c228c06ecaf988d1c60e542176bd7a490e60eeda3493244c0c9"
+checksum = "b7e6f6fa1f03c14ae082120b84b3c7fbd7b8588d924cf2d7c3daf9afd49df8b9"
 dependencies = [
- "darling",
+ "darling 0.21.3",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4869,7 +4872,6 @@ dependencies = [
  "serde_json",
  "settings",
  "smol",
- "theme",
  "ui",
  "util",
  "workspace",
@@ -4881,7 +4883,6 @@ name = "diagnostics"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "client",
  "collections",
  "component",
  "ctor",
@@ -5038,7 +5039,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -5101,7 +5102,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "strum 0.27.2",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -5274,7 +5275,6 @@ dependencies = [
  "thiserror 2.0.17",
  "time",
  "toml 0.8.23",
- "tree-sitter-rust",
  "ui",
  "util",
  "uuid",
@@ -5372,7 +5372,6 @@ dependencies = [
  "tree-sitter",
  "util",
  "zeta_prompt",
- "zlog",
 ]
 
 [[package]]
@@ -5393,7 +5392,6 @@ dependencies = [
  "anyhow",
  "buffer_diff",
  "client",
- "clock",
  "cloud_llm_client",
  "codestral",
  "collections",
@@ -5410,18 +5408,12 @@ dependencies = [
  "gpui",
  "indoc",
  "language",
- "language_model",
- "lsp",
  "markdown",
  "menu",
  "multi_buffer",
  "paths",
- "pretty_assertions",
  "project",
  "regex",
- "release_channel",
- "semver",
- "serde_json",
  "settings",
  "telemetry",
  "text",
@@ -5432,7 +5424,6 @@ dependencies = [
  "workspace",
  "zed_actions",
  "zeta_prompt",
- "zlog",
 ]
 
 [[package]]
@@ -5461,7 +5452,6 @@ dependencies = [
  "fuzzy",
  "git",
  "gpui",
- "http_client",
  "indoc",
  "itertools 0.14.0",
  "language",
@@ -5476,6 +5466,8 @@ dependencies = [
  "parking_lot",
  "pretty_assertions",
  "project",
+ "proptest",
+ "proptest-derive",
  "rand 0.9.2",
  "regex",
  "release_channel",
@@ -5492,7 +5484,6 @@ dependencies = [
  "sum_tree",
  "task",
  "telemetry",
- "tempfile",
  "text",
  "theme",
  "time",
@@ -5652,7 +5643,7 @@ dependencies = [
  "heck 0.5.0",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -5673,7 +5664,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -5738,7 +5729,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -5789,6 +5780,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "error-graph"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b920e777967421aa5f9bf34f842c0ab6ba19b3bdb4a082946093860f5858879"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "etagere"
 version = "0.2.15"
@@ -5892,6 +5892,47 @@ dependencies = [
  "watch",
 ]
 
+[[package]]
+name = "eval_cli"
+version = "0.1.0"
+dependencies = [
+ "acp_thread",
+ "agent",
+ "agent-client-protocol",
+ "agent_ui",
+ "anyhow",
+ "clap",
+ "client",
+ "ctrlc",
+ "debug_adapter_extension",
+ "env_logger 0.11.8",
+ "extension",
+ "feature_flags",
+ "fs",
+ "futures 0.3.31",
+ "gpui",
+ "gpui_platform",
+ "gpui_tokio",
+ "language",
+ "language_extension",
+ "language_model",
+ "language_models",
+ "languages",
+ "node_runtime",
+ "paths",
+ "project",
+ "prompt_store",
+ "release_channel",
+ "reqwest_client",
+ "serde",
+ "serde_json",
+ "settings",
+ "shellexpand 2.1.2",
+ "terminal_view",
+ "util",
+ "watch",
+]
+
 [[package]]
 name = "eval_utils"
 version = "0.1.0"
@@ -6020,7 +6061,9 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_json_lenient",
+ "settings_content",
  "snippet_provider",
+ "task",
  "theme",
  "tokio",
  "toml 0.8.23",
@@ -6057,7 +6100,6 @@ dependencies = [
  "parking_lot",
  "paths",
  "project",
- "rand 0.9.2",
  "release_channel",
  "remote",
  "reqwest_client",
@@ -6117,6 +6159,12 @@ dependencies = [
  "zed_actions",
 ]
 
+[[package]]
+name = "failspot"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c942e64b20ecd39933d5ff938ca4fdb6ef0d298cc3855b231179a5ef0b24948d"
+
 [[package]]
 name = "fallible-iterator"
 version = "0.3.0"
@@ -6172,7 +6220,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -6199,7 +6247,6 @@ dependencies = [
 name = "feature_flags"
 version = "0.1.0"
 dependencies = [
- "futures 0.3.31",
  "gpui",
 ]
 
@@ -6207,7 +6254,6 @@ dependencies = [
 name = "feedback"
 version = "0.1.0"
 dependencies = [
- "editor",
  "gpui",
  "system_specs",
  "urlencoding",
@@ -6231,6 +6277,8 @@ name = "file_finder"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "channel",
+ "client",
  "collections",
  "ctor",
  "editor",
@@ -6238,13 +6286,13 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "gpui",
- "language",
  "menu",
  "open_path_prompt",
  "picker",
  "pretty_assertions",
  "project",
  "project_panel",
+ "remote_connection",
  "serde",
  "serde_json",
  "settings",
@@ -6471,7 +6519,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -6740,7 +6788,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -7130,7 +7178,7 @@ dependencies = [
 [[package]]
 name = "gh-workflow"
 version = "0.8.0"
-source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1072860d96776fa52775b82ac#c9eac0ed361583e1072860d96776fa52775b82ac"
+source = "git+https://github.com/zed-industries/gh-workflow?rev=37f3c0575d379c218a9c455ee67585184e40d43f#37f3c0575d379c218a9c455ee67585184e40d43f"
 dependencies = [
  "async-trait",
  "derive_more",
@@ -7141,17 +7189,17 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_yaml",
- "strum_macros",
+ "strum_macros 0.27.2",
 ]
 
 [[package]]
 name = "gh-workflow-macros"
 version = "0.8.0"
-source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1072860d96776fa52775b82ac#c9eac0ed361583e1072860d96776fa52775b82ac"
+source = "git+https://github.com/zed-industries/gh-workflow?rev=37f3c0575d379c218a9c455ee67585184e40d43f#37f3c0575d379c218a9c455ee67585184e40d43f"
 dependencies = [
  "heck 0.5.0",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -7224,7 +7272,6 @@ dependencies = [
  "text",
  "thiserror 2.0.17",
  "time",
- "unindent",
  "url",
  "urlencoding",
  "util",
@@ -7261,7 +7308,6 @@ dependencies = [
  "menu",
  "project",
  "rand 0.9.2",
- "recent_projects",
  "serde_json",
  "settings",
  "smallvec",
@@ -7312,7 +7358,6 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "git",
- "git_hosting_providers",
  "gpui",
  "indoc",
  "itertools 0.14.0",
@@ -7400,7 +7445,7 @@ dependencies = [
  "proc-macro-crate",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -7481,8 +7526,6 @@ dependencies = [
  "settings",
  "text",
  "theme",
- "tree-sitter-rust",
- "tree-sitter-typescript",
  "ui",
  "util",
  "workspace",
@@ -7501,9 +7544,9 @@ dependencies = [
 
 [[package]]
 name = "goblin"
-version = "0.8.2"
+version = "0.9.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
+checksum = "daa0a64d21a7eb230583b4c5f4e23b7e4e57974f96620f42a7e75e08ae66d745"
 dependencies = [
  "log",
  "plain",
@@ -7613,8 +7656,8 @@ dependencies = [
  "pin-project",
  "pollster 0.4.0",
  "postage",
- "pretty_assertions",
  "profiling",
+ "proptest",
  "rand 0.9.2",
  "raw-window-handle",
  "refineable",
@@ -7742,7 +7785,7 @@ dependencies = [
  "heck 0.5.0",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -8170,7 +8213,7 @@ dependencies = [
  "markup5ever 0.12.1",
  "proc-macro2",
  "quote",
- "syn 2.0.106",
+ "syn 2.0.117",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -66,6 +66,7 @@ members = [
     "crates/encoding_selector",
     "crates/etw_tracing",
     "crates/eval",
+    "crates/eval_cli",
     "crates/eval_utils",
     "crates/explorer_command_injector",
     "crates/extension",
@@ -158,7 +159,6 @@ members = [
     "crates/remote_server",
     "crates/repl",
     "crates/reqwest_client",
-    "crates/rich_text",
     "crates/rope",
     "crates/rpc",
     "crates/rules_library",
@@ -173,7 +173,6 @@ members = [
     "crates/settings_profile_selector",
     "crates/settings_ui",
     "crates/shell_command_parser",
-    "crates/sidebar",
     "crates/snippet",
     "crates/snippet_provider",
     "crates/snippets_ui",
@@ -412,7 +411,6 @@ rules_library = { path = "crates/rules_library" }
 scheduler = { path = "crates/scheduler" }
 search = { path = "crates/search" }
 session = { path = "crates/session" }
-sidebar = { path = "crates/sidebar" }
 settings = { path = "crates/settings" }
 settings_content = { path = "crates/settings_content" }
 settings_json = { path = "crates/settings_json" }
@@ -475,7 +473,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
 # External crates
 #
 
-agent-client-protocol = { version = "=0.9.4", features = ["unstable"] }
+agent-client-protocol = { version = "=0.10.2", features = ["unstable"] }
 aho-corasick = "1.1"
 alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }
 any_vec = "0.14"
@@ -513,7 +511,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
 aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
 backtrace = "0.3"
 base64 = "0.22"
-bincode = "1.2.1"
 bitflags = "2.6.0"
 brotli = "8.0.2"
 bytes = "1.0"
@@ -561,7 +558,7 @@ fork = "0.4.0"
 futures = "0.3"
 futures-concurrency = "7.7.1"
 futures-lite = "1.13"
-gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "c9eac0ed361583e1072860d96776fa52775b82ac" }
+gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" }
 git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] }
 globset = "0.4"
 handlebars = "4.3"
@@ -572,7 +569,6 @@ human_bytes = "0.4.1"
 html5ever = "0.27.0"
 http = "1.1"
 http-body = "1.0"
-hyper = "0.14"
 ignore = "0.4.22"
 image = "0.25.1"
 imara-diff = "0.1.8"
@@ -595,7 +591,7 @@ lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "a4f410
 mach2 = "0.5"
 markup5ever_rcdom = "0.3.0"
 metal = "0.33"
-minidumper = "0.8"
+minidumper = "0.9"
 moka = { version = "0.12.10", features = ["sync"] }
 naga = { version = "28.0", features = ["wgsl-in"] }
 nanoid = "0.4"
@@ -649,6 +645,9 @@ postage = { version = "0.5", features = ["futures-traits"] }
 pretty_assertions = { version = "1.3.0", features = ["unstable"] }
 proc-macro2 = "1.0.93"
 profiling = "1"
+# replace this with main when #635 is merged
+proptest = { git = "https://github.com/proptest-rs/proptest", rev = "3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b", features = ["attr-macro"] }
+proptest-derive = "0.8.0"
 prost = "0.9"
 prost-build = "0.9"
 prost-types = "0.9"
@@ -687,7 +686,6 @@ serde_json_lenient = { version = "0.2", features = [
     "raw_value",
 ] }
 serde_path_to_error = "0.1.17"
-serde_repr = "0.1"
 serde_urlencoded = "0.7"
 sha2 = "0.10"
 shellexpand = "2.1.0"
@@ -718,7 +716,6 @@ time = { version = "0.3", features = [
 ] }
 tiny_http = "0.8"
 tokio = { version = "1" }
-tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
 tokio-socks = { version = "0.5.2", default-features = false, features = [
     "futures-io",
     "tokio",
@@ -779,7 +776,7 @@ wax = "0.7"
 which = "6.0.0"
 wasm-bindgen = "0.2.113"
 web-time = "1.1.0"
-wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "9459e95113c5bd116b2cc2c87e8424b28059e17c" }
+wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "465557eccfe77c840a9b4936f1408da9503372c4" }
 windows-core = "0.61"
 yawc = "0.2.5"
 zeroize = "1.8"
@@ -904,7 +901,6 @@ refineable = { codegen-units = 1 }
 release_channel = { codegen-units = 1 }
 reqwest_client = { codegen-units = 1 }
 session = { codegen-units = 1 }
-sidebar = { codegen-units = 1 }
 snippet = { codegen-units = 1 }
 snippets_ui = { codegen-units = 1 }
 story = { codegen-units = 1 }

Dockerfile-collab 🔗

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

assets/icons/archive.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.4 2.60001H2.6C2.26863 2.60001 2 2.86864 2 3.20001V5.00001C2 5.33138 2.26863 5.60001 2.6 5.60001H13.4C13.7314 5.60001 14 5.33138 14 5.00001V3.20001C14 2.86864 13.7314 2.60001 13.4 2.60001Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.2 5.60004V12.2C3.2 12.5183 3.32643 12.8235 3.55147 13.0486C3.77651 13.2736 4.08174 13.4 4.4 13.4H11.6C11.9183 13.4 12.2235 13.2736 12.4485 13.0486C12.6736 12.8235 12.8 12.5183 12.8 12.2V5.60004" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.8 8H9.2" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/git_merge_conflict.svg 🔗

@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 4.32848H10.4477C10.7723 4.32848 11.0835 4.45742 11.3131 4.68693C11.5426 4.91644 11.6715 5.22773 11.6715 5.55232V9.83575" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.32849 8V13.5073" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.16426 2.49272L2.49274 6.16424" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.16426 6.16424L2.49274 2.49272" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.6715 13.5073C12.6854 13.5073 13.5073 12.6854 13.5073 11.6715C13.5073 10.6577 12.6854 9.83575 11.6715 9.83575C10.6577 9.83575 9.83575 10.6577 9.83575 11.6715C9.83575 12.6854 10.6577 13.5073 11.6715 13.5073Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/list_collapse.svg 🔗

@@ -1 +1,7 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M2.857 6.857 4.286 5.43 2.857 4M2.857 12l1.429-1.429-1.429-1.428M6.857 4.571h6.286M6.857 8h6.286M6.857 11.428h6.286"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 4H7.33333" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2 8H7.33333" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2 12H7.33333" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 4L12 6L14 4" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 12L12 10L14 12" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/thread.svg 🔗

@@ -1,3 +1,4 @@
 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6.31254 12.549C7.3841 13.0987 8.61676 13.2476 9.78839 12.9688C10.96 12.6901 11.9936 12.0021 12.7028 11.0287C13.412 10.0554 13.7503 8.8607 13.6566 7.66002C13.5629 6.45934 13.0435 5.33159 12.1919 4.48C11.3403 3.62841 10.2126 3.10898 9.01188 3.01531C7.8112 2.92164 6.61655 3.2599 5.64319 3.96912C4.66984 4.67834 3.9818 5.71188 3.70306 6.88351C3.42432 8.05514 3.5732 9.2878 4.12289 10.3594L3 13.6719L6.31254 12.549Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path opacity="0.12" d="M6.31254 12.549C7.3841 13.0987 8.61676 13.2476 9.78839 12.9688C10.96 12.6901 11.9936 12.0021 12.7028 11.0287C13.412 10.0554 13.7503 8.8607 13.6566 7.66002C13.5629 6.45934 13.0435 5.33159 12.1919 4.48C11.3403 3.62841 10.2126 3.10898 9.01188 3.01531C7.8112 2.92164 6.61655 3.2599 5.64319 3.96912C4.66984 4.67834 3.9818 5.71188 3.70306 6.88351C3.42432 8.05514 3.5732 9.2878 4.12289 10.3594L3 13.6719L6.31254 12.549Z" fill="#C6CAD0"/>
+<path d="M5.97658 12.549C7.04814 13.0987 8.2808 13.2476 9.45243 12.9688C10.624 12.6901 11.6576 12.0021 12.3668 11.0287C13.076 10.0554 13.4143 8.8607 13.3206 7.66002C13.2269 6.45934 12.7075 5.33159 11.8559 4.48C11.0043 3.62841 9.87664 3.10898 8.67592 3.01531C7.47524 2.92164 6.28059 3.2599 5.30723 3.96912C4.33388 4.67834 3.64584 5.71188 3.3671 6.88351C3.08836 8.05514 3.23724 9.2878 3.78693 10.3594L2.66404 13.6719L5.97658 12.549Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
 </svg>

assets/icons/threads_sidebar_left_closed.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.1" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 7 2)" fill="#C6CAD0"/>
+<path d="M7 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/threads_sidebar_left_open.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.8" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 7 2)" fill="#C6CAD0"/>
+<path d="M7 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/threads_sidebar_right_closed.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.1" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 14 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/threads_sidebar_right_open.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.8" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 14 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/workspace_nav_closed.svg 🔗

@@ -1,5 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect opacity="0.2" width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
-<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
-<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
-</svg>

assets/icons/workspace_nav_open.svg 🔗

@@ -1,5 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
-<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
-<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
-</svg>

assets/keymaps/default-linux.json 🔗

@@ -226,8 +226,8 @@
     "context": "ContextEditor > Editor",
     "bindings": {
       "ctrl-enter": "assistant::Assist",
-      "ctrl-s": "workspace::Save",
       "save": "workspace::Save",
+      "ctrl-s": "workspace::Save",
       "ctrl-<": "assistant::InsertIntoEditor",
       "shift-enter": "assistant::Split",
       "ctrl-r": "assistant::CycleMessageRole",
@@ -258,6 +258,7 @@
       "ctrl-shift-j": "agent::ToggleNavigationMenu",
       "ctrl-alt-i": "agent::ToggleOptionsMenu",
       "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
+      "ctrl-shift-t": "agent::CycleStartThreadIn",
       "shift-alt-escape": "agent::ExpandMessageEditor",
       "ctrl->": "agent::AddSelectionToThread",
       "ctrl-shift-e": "project_panel::ToggleFocus",
@@ -623,6 +624,7 @@
       "ctrl-shift-t": "pane::ReopenClosedItem",
       "ctrl-k ctrl-s": "zed::OpenKeymap",
       "ctrl-k ctrl-t": "theme_selector::Toggle",
+      "ctrl-k ctrl-shift-t": "theme::ToggleMode",
       "ctrl-alt-super-p": "settings_profile_selector::Toggle",
       "ctrl-t": "project_symbols::Toggle",
       "ctrl-p": "file_finder::Toggle",
@@ -818,7 +820,7 @@
     },
   },
   {
-    "context": "!ContextEditor > Editor && mode == full",
+    "context": "!ContextEditor && !AcpThread > Editor && mode == full",
     "bindings": {
       "alt-enter": "editor::OpenExcerpts",
       "shift-enter": "editor::ExpandExcerpts",
@@ -982,6 +984,7 @@
       "ctrl-shift-enter": "git::Amend",
       "ctrl-space": "git::StageAll",
       "ctrl-shift-space": "git::UnstageAll",
+      "ctrl-k ctrl-r": "git::RestoreAndNext",
     },
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -297,6 +297,7 @@
       "cmd-shift-j": "agent::ToggleNavigationMenu",
       "cmd-alt-m": "agent::ToggleOptionsMenu",
       "cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
+      "cmd-shift-t": "agent::CycleStartThreadIn",
       "shift-alt-escape": "agent::ExpandMessageEditor",
       "cmd->": "agent::AddSelectionToThread",
       "cmd-shift-e": "project_panel::ToggleFocus",
@@ -690,6 +691,7 @@
       "cmd-shift-t": "pane::ReopenClosedItem",
       "cmd-k cmd-s": "zed::OpenKeymap",
       "cmd-k cmd-t": "theme_selector::Toggle",
+      "cmd-k cmd-shift-t": "theme::ToggleMode",
       "ctrl-alt-cmd-p": "settings_profile_selector::Toggle",
       "cmd-t": "project_symbols::Toggle",
       "cmd-p": "file_finder::Toggle",
@@ -881,7 +883,7 @@
     },
   },
   {
-    "context": "!ContextEditor > Editor && mode == full",
+    "context": "!ContextEditor && !AcpThread > Editor && mode == full",
     "use_key_equivalents": true,
     "bindings": {
       "alt-enter": "editor::OpenExcerpts",
@@ -1033,6 +1035,7 @@
       "cmd-shift-enter": "git::Amend",
       "cmd-ctrl-y": "git::StageAll",
       "cmd-ctrl-shift-y": "git::UnstageAll",
+      "cmd-alt-z": "git::RestoreAndNext",
     },
   },
   {

assets/keymaps/default-windows.json 🔗

@@ -259,6 +259,7 @@
       "shift-alt-j": "agent::ToggleNavigationMenu",
       "shift-alt-i": "agent::ToggleOptionsMenu",
       "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
+      "ctrl-shift-t": "agent::CycleStartThreadIn",
       "shift-alt-escape": "agent::ExpandMessageEditor",
       "ctrl-shift-.": "agent::AddSelectionToThread",
       "ctrl-shift-e": "project_panel::ToggleFocus",
@@ -615,6 +616,7 @@
       "ctrl-shift-t": "pane::ReopenClosedItem",
       "ctrl-k ctrl-s": "zed::OpenKeymap",
       "ctrl-k ctrl-t": "theme_selector::Toggle",
+      "ctrl-k ctrl-shift-t": "theme::ToggleMode",
       "ctrl-alt-super-p": "settings_profile_selector::Toggle",
       "ctrl-t": "project_symbols::Toggle",
       "ctrl-p": "file_finder::Toggle",
@@ -820,7 +822,7 @@
     },
   },
   {
-    "context": "!ContextEditor > Editor && mode == full",
+    "context": "!ContextEditor && !AcpThread > Editor && mode == full",
     "use_key_equivalents": true,
     "bindings": {
       "alt-enter": "editor::OpenExcerpts",
@@ -983,6 +985,7 @@
       "ctrl-shift-enter": "git::Amend",
       "ctrl-space": "git::StageAll",
       "ctrl-shift-space": "git::UnstageAll",
+      "ctrl-k ctrl-r": "git::RestoreAndNext",
     },
   },
   {

assets/keymaps/linux/jetbrains.json 🔗

@@ -81,6 +81,13 @@
       "ctrl-\\": "assistant::InlineAssist",
     },
   },
+  {
+    "context": "Editor && mode == auto_height",
+    "bindings": {
+      "shift-enter": "editor::Newline",
+      "ctrl-shift-enter": "editor::NewlineBelow",
+    },
+  },
   {
     "context": "BufferSearchBar",
     "bindings": {

assets/keymaps/macos/jetbrains.json 🔗

@@ -33,6 +33,7 @@
       "cmd-+": "editor::UnfoldLines",
       "alt-shift-g": "editor::SplitSelectionIntoLines",
       "ctrl-g": ["editor::SelectNext", { "replace_newest": false }],
+      "ctrl-shift-g": "editor::UndoSelection",
       "ctrl-cmd-g": ["editor::SelectPrevious", { "replace_newest": false }],
       "cmd-/": ["editor::ToggleComments", { "advance_downwards": true }],
       "alt-up": "editor::SelectLargerSyntaxNode",
@@ -79,6 +80,13 @@
       "cmd-\\": "assistant::InlineAssist",
     },
   },
+  {
+    "context": "Editor && mode == auto_height",
+    "bindings": {
+      "shift-enter": "editor::Newline",
+      "ctrl-shift-enter": "editor::NewlineBelow",
+    },
+  },
   {
     "context": "BufferSearchBar",
     "bindings": {

assets/keymaps/vim.json 🔗

@@ -427,6 +427,7 @@
       "escape": "vim::SwitchToHelixNormalMode",
       "i": "vim::HelixInsert",
       "a": "vim::HelixAppend",
+      "shift-a": "vim::HelixInsertEndOfLine",
       "ctrl-[": "editor::Cancel",
     },
   },

assets/settings/default.json 🔗

@@ -768,6 +768,9 @@
       // 5. Never show the scrollbar:
       //    "never"
       "show": null,
+      // Whether to allow horizontal scrolling in the project panel.
+      // When false, the view is locked to the leftmost position and long file names are clipped.
+      "horizontal_scroll": true,
     },
     // Which files containing diagnostic errors/warnings to mark in the project panel.
     // This setting can take the following three values:
@@ -920,8 +923,8 @@
     },
     // Whether to show the addition/deletion change count next to each file in the Git panel.
     //
-    // Default: false
-    "diff_stats": false,
+    // Default: true
+    "diff_stats": true,
   },
   "message_editor": {
     // Whether to automatically replace emoji shortcodes with emoji characters.
@@ -1282,6 +1285,8 @@
     //   * "indexed": Use only the files Zed had indexed
     //   * "smart": Be smart and search for ignored when called from a gitignored worktree
     "include_ignored": "smart",
+    // Whether to include text channels in file finder results.
+    "include_channels": false,
   },
   // Whether or not to remove any trailing whitespace from lines of a buffer
   // before saving it.

crates/acp_thread/Cargo.toml 🔗

@@ -59,7 +59,5 @@ indoc.workspace = true
 parking_lot.workspace = true
 project = { workspace = true, "features" = ["test-support"] }
 rand.workspace = true
-tempfile.workspace = true
 util.workspace = true
 settings.workspace = true
-zlog.workspace = true

crates/acp_thread/src/acp_thread.rs 🔗

@@ -952,8 +952,11 @@ struct RunningTurn {
 }
 
 pub struct AcpThread {
+    session_id: acp::SessionId,
+    cwd: Option<PathBuf>,
     parent_session_id: Option<acp::SessionId>,
     title: SharedString,
+    provisional_title: Option<SharedString>,
     entries: Vec<AgentThreadEntry>,
     plan: Plan,
     project: Entity<Project>,
@@ -962,7 +965,6 @@ pub struct AcpThread {
     turn_id: u32,
     running_turn: Option<RunningTurn>,
     connection: Rc<dyn AgentConnection>,
-    session_id: acp::SessionId,
     token_usage: Option<TokenUsage>,
     prompt_capabilities: acp::PromptCapabilities,
     _observe_prompt_capabilities: Task<anyhow::Result<()>>,
@@ -1047,87 +1049,6 @@ pub enum TerminalProviderCommand {
     },
 }
 
-impl AcpThread {
-    pub fn on_terminal_provider_event(
-        &mut self,
-        event: TerminalProviderEvent,
-        cx: &mut Context<Self>,
-    ) {
-        match event {
-            TerminalProviderEvent::Created {
-                terminal_id,
-                label,
-                cwd,
-                output_byte_limit,
-                terminal,
-            } => {
-                let entity = self.register_terminal_created(
-                    terminal_id.clone(),
-                    label,
-                    cwd,
-                    output_byte_limit,
-                    terminal,
-                    cx,
-                );
-
-                if let Some(mut chunks) = self.pending_terminal_output.remove(&terminal_id) {
-                    for data in chunks.drain(..) {
-                        entity.update(cx, |term, cx| {
-                            term.inner().update(cx, |inner, cx| {
-                                inner.write_output(&data, cx);
-                            })
-                        });
-                    }
-                }
-
-                if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) {
-                    entity.update(cx, |_term, cx| {
-                        cx.notify();
-                    });
-                }
-
-                cx.notify();
-            }
-            TerminalProviderEvent::Output { terminal_id, data } => {
-                if let Some(entity) = self.terminals.get(&terminal_id) {
-                    entity.update(cx, |term, cx| {
-                        term.inner().update(cx, |inner, cx| {
-                            inner.write_output(&data, cx);
-                        })
-                    });
-                } else {
-                    self.pending_terminal_output
-                        .entry(terminal_id)
-                        .or_default()
-                        .push(data);
-                }
-            }
-            TerminalProviderEvent::TitleChanged { terminal_id, title } => {
-                if let Some(entity) = self.terminals.get(&terminal_id) {
-                    entity.update(cx, |term, cx| {
-                        term.inner().update(cx, |inner, cx| {
-                            inner.breadcrumb_text = title;
-                            cx.emit(::terminal::Event::BreadcrumbsChanged);
-                        })
-                    });
-                }
-            }
-            TerminalProviderEvent::Exit {
-                terminal_id,
-                status,
-            } => {
-                if let Some(entity) = self.terminals.get(&terminal_id) {
-                    entity.update(cx, |_term, cx| {
-                        cx.notify();
-                    });
-                } else {
-                    self.pending_terminal_exit.insert(terminal_id, status);
-                }
-            }
-        }
-    }
-}
-
 #[derive(PartialEq, Eq, Debug)]
 pub enum ThreadStatus {
     Idle,
@@ -1174,6 +1095,7 @@ impl AcpThread {
     pub fn new(
         parent_session_id: Option<acp::SessionId>,
         title: impl Into<SharedString>,
+        cwd: Option<PathBuf>,
         connection: Rc<dyn AgentConnection>,
         project: Entity<Project>,
         action_log: Entity<ActionLog>,
@@ -1194,11 +1116,13 @@ impl AcpThread {
 
         Self {
             parent_session_id,
+            cwd,
             action_log,
             shared_buffers: Default::default(),
             entries: Default::default(),
             plan: Default::default(),
             title: title.into(),
+            provisional_title: None,
             project,
             running_turn: None,
             turn_id: 0,
@@ -1253,7 +1177,9 @@ impl AcpThread {
     }
 
     pub fn title(&self) -> SharedString {
-        self.title.clone()
+        self.provisional_title
+            .clone()
+            .unwrap_or_else(|| self.title.clone())
     }
 
     pub fn entries(&self) -> &[AgentThreadEntry] {
@@ -1264,6 +1190,10 @@ impl AcpThread {
         &self.session_id
     }
 
+    pub fn cwd(&self) -> Option<&PathBuf> {
+        self.cwd.as_ref()
+    }
+
     pub fn status(&self) -> ThreadStatus {
         if self.running_turn.is_some() {
             ThreadStatus::Generating
@@ -1505,16 +1435,29 @@ impl AcpThread {
     }
 
     pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let had_provisional = self.provisional_title.take().is_some();
         if title != self.title {
             self.title = title.clone();
             cx.emit(AcpThreadEvent::TitleUpdated);
             if let Some(set_title) = self.connection.set_title(&self.session_id, cx) {
                 return set_title.run(title, cx);
             }
+        } else if had_provisional {
+            cx.emit(AcpThreadEvent::TitleUpdated);
         }
         Task::ready(Ok(()))
     }
 
+    /// Sets a provisional display title without propagating back to the
+    /// underlying agent connection. This is used for quick preview titles
+    /// (e.g. first 20 chars of the user message) that should be shown
+    /// immediately but replaced once the LLM generates a proper title via
+    /// `set_title`.
+    pub fn set_provisional_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
+        self.provisional_title = Some(title);
+        cx.emit(AcpThreadEvent::TitleUpdated);
+    }
+
     pub fn subagent_spawned(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
         cx.emit(AcpThreadEvent::SubagentSpawned(session_id));
     }
@@ -2607,6 +2550,85 @@ impl AcpThread {
             }
         }
     }
+
+    pub fn on_terminal_provider_event(
+        &mut self,
+        event: TerminalProviderEvent,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            TerminalProviderEvent::Created {
+                terminal_id,
+                label,
+                cwd,
+                output_byte_limit,
+                terminal,
+            } => {
+                let entity = self.register_terminal_created(
+                    terminal_id.clone(),
+                    label,
+                    cwd,
+                    output_byte_limit,
+                    terminal,
+                    cx,
+                );
+
+                if let Some(mut chunks) = self.pending_terminal_output.remove(&terminal_id) {
+                    for data in chunks.drain(..) {
+                        entity.update(cx, |term, cx| {
+                            term.inner().update(cx, |inner, cx| {
+                                inner.write_output(&data, cx);
+                            })
+                        });
+                    }
+                }
+
+                if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) {
+                    entity.update(cx, |_term, cx| {
+                        cx.notify();
+                    });
+                }
+
+                cx.notify();
+            }
+            TerminalProviderEvent::Output { terminal_id, data } => {
+                if let Some(entity) = self.terminals.get(&terminal_id) {
+                    entity.update(cx, |term, cx| {
+                        term.inner().update(cx, |inner, cx| {
+                            inner.write_output(&data, cx);
+                        })
+                    });
+                } else {
+                    self.pending_terminal_output
+                        .entry(terminal_id)
+                        .or_default()
+                        .push(data);
+                }
+            }
+            TerminalProviderEvent::TitleChanged { terminal_id, title } => {
+                if let Some(entity) = self.terminals.get(&terminal_id) {
+                    entity.update(cx, |term, cx| {
+                        term.inner().update(cx, |inner, cx| {
+                            inner.breadcrumb_text = title;
+                            cx.emit(::terminal::Event::BreadcrumbsChanged);
+                        })
+                    });
+                }
+            }
+            TerminalProviderEvent::Exit {
+                terminal_id,
+                status,
+            } => {
+                if let Some(entity) = self.terminals.get(&terminal_id) {
+                    entity.update(cx, |_term, cx| {
+                        cx.notify();
+                    });
+                } else {
+                    self.pending_terminal_exit.insert(terminal_id, status);
+                }
+            }
+        }
+    }
 }
 
 fn markdown_for_raw_output(
@@ -3916,6 +3938,7 @@ mod tests {
     struct FakeAgentConnection {
         auth_methods: Vec<acp::AuthMethod>,
         sessions: Arc<parking_lot::Mutex<HashMap<acp::SessionId, WeakEntity<AcpThread>>>>,
+        set_title_calls: Rc<RefCell<Vec<SharedString>>>,
         on_user_message: Option<
             Rc<
                 dyn Fn(
@@ -3934,6 +3957,7 @@ mod tests {
                 auth_methods: Vec::new(),
                 on_user_message: None,
                 sessions: Arc::default(),
+                set_title_calls: Default::default(),
             }
         }
 
@@ -3969,7 +3993,7 @@ mod tests {
         fn new_session(
             self: Rc<Self>,
             project: Entity<Project>,
-            _cwd: &Path,
+            cwd: &Path,
             cx: &mut App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             let session_id = acp::SessionId::new(
@@ -3984,6 +4008,7 @@ mod tests {
                 AcpThread::new(
                     None,
                     "Test",
+                    Some(cwd.to_path_buf()),
                     self.clone(),
                     project,
                     action_log,
@@ -4002,7 +4027,7 @@ mod tests {
         }
 
         fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task<gpui::Result<()>> {
-            if self.auth_methods().iter().any(|m| m.id == method) {
+            if self.auth_methods().iter().any(|m| m.id() == &method) {
                 Task::ready(Ok(()))
             } else {
                 Task::ready(Err(anyhow!("Invalid Auth Method")))
@@ -4038,11 +4063,32 @@ mod tests {
             }))
         }
 
+        fn set_title(
+            &self,
+            _session_id: &acp::SessionId,
+            _cx: &App,
+        ) -> Option<Rc<dyn AgentSessionSetTitle>> {
+            Some(Rc::new(FakeAgentSessionSetTitle {
+                calls: self.set_title_calls.clone(),
+            }))
+        }
+
         fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
             self
         }
     }
 
+    struct FakeAgentSessionSetTitle {
+        calls: Rc<RefCell<Vec<SharedString>>>,
+    }
+
+    impl AgentSessionSetTitle for FakeAgentSessionSetTitle {
+        fn run(&self, title: SharedString, _cx: &mut App) -> Task<Result<()>> {
+            self.calls.borrow_mut().push(title);
+            Task::ready(Ok(()))
+        }
+    }
+
     struct FakeAgentSessionEditor {
         _session_id: acp::SessionId,
     }
@@ -4634,4 +4680,54 @@ mod tests {
             );
         });
     }
+
+    #[gpui::test]
+    async fn test_provisional_title_replaced_by_real_title(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let connection = Rc::new(FakeAgentConnection::new());
+        let set_title_calls = connection.set_title_calls.clone();
+
+        let thread = cx
+            .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx))
+            .await
+            .unwrap();
+
+        // Initial title is the default.
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.title().as_ref(), "Test");
+        });
+
+        // Setting a provisional title updates the display title.
+        thread.update(cx, |thread, cx| {
+            thread.set_provisional_title("Hello, can you help…".into(), cx);
+        });
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.title().as_ref(), "Hello, can you help…");
+        });
+
+        // The provisional title should NOT have propagated to the connection.
+        assert_eq!(
+            set_title_calls.borrow().len(),
+            0,
+            "provisional title should not propagate to the connection"
+        );
+
+        // When the real title arrives via set_title, it replaces the
+        // provisional title and propagates to the connection.
+        let task = thread.update(cx, |thread, cx| {
+            thread.set_title("Helping with Rust question".into(), cx)
+        });
+        task.await.expect("set_title should succeed");
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.title().as_ref(), "Helping with Rust question");
+        });
+        assert_eq!(
+            set_title_calls.borrow().as_slice(),
+            &[SharedString::from("Helping with Rust question")],
+            "real title should propagate to the connection"
+        );
+    }
 }

crates/acp_thread/src/connection.rs 🔗

@@ -45,9 +45,10 @@ pub trait AgentConnection {
     /// Load an existing session by ID.
     fn load_session(
         self: Rc<Self>,
-        _session: AgentSessionInfo,
+        _session_id: acp::SessionId,
         _project: Entity<Project>,
         _cwd: &Path,
+        _title: Option<SharedString>,
         _cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
         Task::ready(Err(anyhow::Error::msg("Loading sessions is not supported")))
@@ -59,7 +60,11 @@ pub trait AgentConnection {
     }
 
     /// Close an existing session. Allows the agent to free the session from memory.
-    fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
+    fn close_session(
+        self: Rc<Self>,
+        _session_id: &acp::SessionId,
+        _cx: &mut App,
+    ) -> Task<Result<()>> {
         Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported")))
     }
 
@@ -71,9 +76,10 @@ pub trait AgentConnection {
     /// Resume an existing session by ID without replaying previous messages.
     fn resume_session(
         self: Rc<Self>,
-        _session: AgentSessionInfo,
+        _session_id: acp::SessionId,
         _project: Entity<Project>,
         _cwd: &Path,
+        _title: Option<SharedString>,
         _cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
         Task::ready(Err(anyhow::Error::msg(
@@ -240,6 +246,7 @@ pub struct AgentSessionInfo {
     pub cwd: Option<PathBuf>,
     pub title: Option<SharedString>,
     pub updated_at: Option<DateTime<Utc>>,
+    pub created_at: Option<DateTime<Utc>>,
     pub meta: Option<acp::Meta>,
 }
 
@@ -250,6 +257,7 @@ impl AgentSessionInfo {
             cwd: None,
             title: None,
             updated_at: None,
+            created_at: None,
             meta: None,
         }
     }
@@ -619,7 +627,7 @@ mod test_support {
         fn new_session(
             self: Rc<Self>,
             project: Entity<Project>,
-            _cwd: &Path,
+            cwd: &Path,
             cx: &mut gpui::App,
         ) -> Task<gpui::Result<Entity<AcpThread>>> {
             static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0);
@@ -630,6 +638,7 @@ mod test_support {
                 AcpThread::new(
                     None,
                     "Test",
+                    Some(cwd.to_path_buf()),
                     self.clone(),
                     project,
                     action_log,

crates/acp_thread/src/mention.rs 🔗

@@ -60,6 +60,9 @@ pub enum MentionUri {
     GitDiff {
         base_ref: String,
     },
+    MergeConflict {
+        file_path: String,
+    },
 }
 
 impl MentionUri {
@@ -215,6 +218,9 @@ impl MentionUri {
                     let base_ref =
                         single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string());
                     Ok(Self::GitDiff { base_ref })
+                } else if path.starts_with("/agent/merge-conflict") {
+                    let file_path = single_query_param(&url, "path")?.unwrap_or_default();
+                    Ok(Self::MergeConflict { file_path })
                 } else {
                     bail!("invalid zed url: {:?}", input);
                 }
@@ -245,6 +251,13 @@ impl MentionUri {
                 }
             }
             MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref),
+            MentionUri::MergeConflict { file_path } => {
+                let name = Path::new(file_path)
+                    .file_name()
+                    .unwrap_or_default()
+                    .to_string_lossy();
+                format!("Merge Conflict ({name})")
+            }
             MentionUri::Selection {
                 abs_path: path,
                 line_range,
@@ -306,6 +319,7 @@ impl MentionUri {
             MentionUri::Selection { .. } => IconName::Reader.path().into(),
             MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
             MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(),
+            MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(),
         }
     }
 
@@ -409,6 +423,11 @@ impl MentionUri {
                 url.query_pairs_mut().append_pair("base", base_ref);
                 url
             }
+            MentionUri::MergeConflict { file_path } => {
+                let mut url = Url::parse("zed:///agent/merge-conflict").unwrap();
+                url.query_pairs_mut().append_pair("path", file_path);
+                url
+            }
         }
     }
 }

crates/action_log/Cargo.toml 🔗

@@ -37,7 +37,7 @@ collections = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
 ctor.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
-indoc.workspace = true
+
 language = { workspace = true, features = ["test-support"] }
 log.workspace = true
 pretty_assertions.workspace = true

crates/action_log/src/action_log.rs 🔗

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

crates/activity_indicator/Cargo.toml 🔗

@@ -30,4 +30,4 @@ workspace.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }
-release_channel.workspace = true
+

crates/agent/Cargo.toml 🔗

@@ -100,9 +100,9 @@ rand.workspace = true
 reqwest_client.workspace = true
 settings = { workspace = true, "features" = ["test-support"] }
 tempfile.workspace = true
-terminal = { workspace = true, "features" = ["test-support"] }
+
 theme = { workspace = true, "features" = ["test-support"] }
-tree-sitter-rust.workspace = true
+
 unindent = { workspace = true }
-worktree = { workspace = true, "features" = ["test-support"] }
+
 zlog.workspace = true

crates/agent/src/agent.rs 🔗

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

crates/agent/src/db.rs 🔗

@@ -45,6 +45,7 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo {
             cwd: None,
             title: Some(meta.title.clone()),
             updated_at: Some(meta.updated_at),
+            created_at: meta.created_at,
             meta: None,
         }
     }
@@ -482,7 +483,10 @@ impl ThreadsDatabase {
         let data_type = DataType::Zstd;
         let data = compressed;
 
-        let created_at = Utc::now().to_rfc3339();
+        // Use the thread's updated_at as created_at for new threads.
+        // This ensures the creation time reflects when the thread was conceptually
+        // created, not when it was saved to the database.
+        let created_at = updated_at.clone();
 
         let mut insert = connection.exec_bound::<(Arc<str>, Option<Arc<str>>, Option<String>, Option<String>, String, String, DataType, Vec<u8>, String)>(indoc! {"
             INSERT INTO threads (id, parent_id, folder_paths, folder_paths_order, summary, updated_at, data_type, data, created_at)

crates/agent/src/edit_agent/evals.rs 🔗

@@ -1423,7 +1423,7 @@ impl EditAgentTest {
             let client = Client::production(cx);
             let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
             settings::init(cx);
-            language_model::init(client.clone(), cx);
+            language_model::init(user_store.clone(), client.clone(), cx);
             language_models::init(user_store, client.clone(), cx);
         });
 

crates/agent/src/native_agent_server.rs 🔗

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

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

@@ -3167,7 +3167,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
         let clock = Arc::new(clock::FakeSystemClock::new());
         let client = Client::new(clock, http_client, cx);
         let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-        language_model::init(client.clone(), cx);
+        language_model::init(user_store.clone(), client.clone(), cx);
         language_models::init(user_store, client.clone(), cx);
         LanguageModelRegistry::test(cx);
     });
@@ -3181,16 +3181,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
 
     // Create agent and connection
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store,
-        templates.clone(),
-        None,
-        fake_fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx
+        .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx));
     let connection = NativeAgentConnection(agent.clone());
 
     // Create a thread using new_thread
@@ -3616,7 +3608,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input(
     let fake_model = model.as_fake();
 
     thread.update(cx, |thread, _cx| {
-        thread.add_tool(StreamingEchoTool);
+        thread.add_tool(StreamingEchoTool::new());
     });
 
     let _events = thread
@@ -3768,7 +3760,8 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
                             InfiniteTool::NAME: true,
                             CancellationAwareTool::NAME: true,
                             StreamingEchoTool::NAME: true,
-                            (TerminalTool::NAME): true,
+                            StreamingFailingEchoTool::NAME: true,
+                            TerminalTool::NAME: true,
                         }
                     }
                 }
@@ -3790,7 +3783,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
                 cx.set_http_client(Arc::new(http_client));
                 let client = Client::production(cx);
                 let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-                language_model::init(client.clone(), cx);
+                language_model::init(user_store.clone(), client.clone(), cx);
                 language_models::init(user_store, client.clone(), cx);
             }
         };
@@ -4387,16 +4380,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -4529,16 +4515,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -4684,16 +4663,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -4821,16 +4793,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -5200,16 +5165,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -5333,16 +5291,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -5514,16 +5465,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
     .await;
     let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
     let thread_store = cx.new(|cx| ThreadStore::new(cx));
-    let agent = NativeAgent::new(
-        project.clone(),
-        thread_store.clone(),
-        Templates::new(),
-        None,
-        fs.clone(),
-        &mut cx.to_async(),
-    )
-    .await
-    .unwrap();
+    let agent = cx.update(|cx| {
+        NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+    });
     let connection = Rc::new(NativeAgentConnection(agent.clone()));
 
     let acp_thread = cx
@@ -6335,3 +6279,196 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) {
         );
     });
 }
+
+#[gpui::test]
+async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestAppContext) {
+    init_test(cx);
+    always_allow_tools(cx);
+
+    let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+    let fake_model = model.as_fake();
+
+    thread.update(cx, |thread, _cx| {
+        thread.add_tool(StreamingFailingEchoTool {
+            receive_chunks_until_failure: 1,
+        });
+    });
+
+    let _events = thread
+        .update(cx, |thread, cx| {
+            thread.send(
+                UserMessageId::new(),
+                ["Use the streaming_failing_echo tool"],
+                cx,
+            )
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    let tool_use = LanguageModelToolUse {
+        id: "call_1".into(),
+        name: StreamingFailingEchoTool::NAME.into(),
+        raw_input: "hello".into(),
+        input: json!({}),
+        is_input_complete: false,
+        thought_signature: None,
+    };
+
+    fake_model
+        .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
+
+    cx.run_until_parked();
+
+    let completions = fake_model.pending_completions();
+    let last_completion = completions.last().unwrap();
+
+    assert_eq!(
+        last_completion.messages[1..],
+        vec![
+            LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec!["Use the streaming_failing_echo tool".into()],
+                cache: false,
+                reasoning_details: None,
+            },
+            LanguageModelRequestMessage {
+                role: Role::Assistant,
+                content: vec![language_model::MessageContent::ToolUse(tool_use.clone())],
+                cache: false,
+                reasoning_details: None,
+            },
+            LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![language_model::MessageContent::ToolResult(
+                    LanguageModelToolResult {
+                        tool_use_id: tool_use.id.clone(),
+                        tool_name: tool_use.name,
+                        is_error: true,
+                        content: "failed".into(),
+                        output: Some("failed".into()),
+                    }
+                )],
+                cache: true,
+                reasoning_details: None,
+            },
+        ]
+    );
+}
+
+#[gpui::test]
+async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut TestAppContext) {
+    init_test(cx);
+    always_allow_tools(cx);
+
+    let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+    let fake_model = model.as_fake();
+
+    let (complete_streaming_echo_tool_call_tx, complete_streaming_echo_tool_call_rx) =
+        oneshot::channel();
+
+    thread.update(cx, |thread, _cx| {
+        thread.add_tool(
+            StreamingEchoTool::new().with_wait_until_complete(complete_streaming_echo_tool_call_rx),
+        );
+        thread.add_tool(StreamingFailingEchoTool {
+            receive_chunks_until_failure: 1,
+        });
+    });
+
+    let _events = thread
+        .update(cx, |thread, cx| {
+            thread.send(
+                UserMessageId::new(),
+                ["Use the streaming_echo tool and the streaming_failing_echo tool"],
+                cx,
+            )
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+        LanguageModelToolUse {
+            id: "call_1".into(),
+            name: StreamingEchoTool::NAME.into(),
+            raw_input: "hello".into(),
+            input: json!({ "text": "hello" }),
+            is_input_complete: false,
+            thought_signature: None,
+        },
+    ));
+    let first_tool_use = LanguageModelToolUse {
+        id: "call_1".into(),
+        name: StreamingEchoTool::NAME.into(),
+        raw_input: "hello world".into(),
+        input: json!({ "text": "hello world" }),
+        is_input_complete: true,
+        thought_signature: None,
+    };
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+        first_tool_use.clone(),
+    ));
+    let second_tool_use = LanguageModelToolUse {
+        name: StreamingFailingEchoTool::NAME.into(),
+        raw_input: "hello".into(),
+        input: json!({ "text": "hello" }),
+        is_input_complete: false,
+        thought_signature: None,
+        id: "call_2".into(),
+    };
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+        second_tool_use.clone(),
+    ));
+
+    cx.run_until_parked();
+
+    complete_streaming_echo_tool_call_tx.send(()).unwrap();
+
+    cx.run_until_parked();
+
+    let completions = fake_model.pending_completions();
+    let last_completion = completions.last().unwrap();
+
+    assert_eq!(
+        last_completion.messages[1..],
+        vec![
+            LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![
+                    "Use the streaming_echo tool and the streaming_failing_echo tool".into()
+                ],
+                cache: false,
+                reasoning_details: None,
+            },
+            LanguageModelRequestMessage {
+                role: Role::Assistant,
+                content: vec![
+                    language_model::MessageContent::ToolUse(first_tool_use.clone()),
+                    language_model::MessageContent::ToolUse(second_tool_use.clone())
+                ],
+                cache: false,
+                reasoning_details: None,
+            },
+            LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![
+                    language_model::MessageContent::ToolResult(LanguageModelToolResult {
+                        tool_use_id: second_tool_use.id.clone(),
+                        tool_name: second_tool_use.name,
+                        is_error: true,
+                        content: "failed".into(),
+                        output: Some("failed".into()),
+                    }),
+                    language_model::MessageContent::ToolResult(LanguageModelToolResult {
+                        tool_use_id: first_tool_use.id.clone(),
+                        tool_name: first_tool_use.name,
+                        is_error: false,
+                        content: "hello world".into(),
+                        output: Some("hello world".into()),
+                    }),
+                ],
+                cache: true,
+                reasoning_details: None,
+            },
+        ]
+    );
+}

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

@@ -2,6 +2,7 @@ use super::*;
 use agent_settings::AgentSettings;
 use gpui::{App, SharedString, Task};
 use std::future;
+use std::sync::Mutex;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::time::Duration;
 
@@ -14,7 +15,22 @@ pub struct StreamingEchoToolInput {
     pub text: String,
 }
 
-pub struct StreamingEchoTool;
+pub struct StreamingEchoTool {
+    wait_until_complete_rx: Mutex<Option<oneshot::Receiver<()>>>,
+}
+
+impl StreamingEchoTool {
+    pub fn new() -> Self {
+        Self {
+            wait_until_complete_rx: Mutex::new(None),
+        }
+    }
+
+    pub fn with_wait_until_complete(mut self, receiver: oneshot::Receiver<()>) -> Self {
+        self.wait_until_complete_rx = Mutex::new(Some(receiver));
+        self
+    }
+}
 
 impl AgentTool for StreamingEchoTool {
     type Input = StreamingEchoToolInput;
@@ -44,17 +60,72 @@ impl AgentTool for StreamingEchoTool {
         _event_stream: ToolCallEventStream,
         cx: &mut App,
     ) -> Task<Result<String, String>> {
+        let wait_until_complete_rx = self.wait_until_complete_rx.lock().unwrap().take();
         cx.spawn(async move |_cx| {
             while input.recv_partial().await.is_some() {}
             let input = input
                 .recv()
                 .await
                 .map_err(|e| format!("Failed to receive tool input: {e}"))?;
+            if let Some(rx) = wait_until_complete_rx {
+                rx.await.ok();
+            }
             Ok(input.text)
         })
     }
 }
 
+/// A streaming tool that echoes its input, used to test streaming tool
+/// lifecycle (e.g. partial delivery and cleanup when the LLM stream ends
+/// before `is_input_complete`).
+#[derive(JsonSchema, Serialize, Deserialize)]
+pub struct StreamingFailingEchoToolInput {
+    /// The text to echo.
+    pub text: String,
+}
+
+pub struct StreamingFailingEchoTool {
+    pub receive_chunks_until_failure: usize,
+}
+
+impl AgentTool for StreamingFailingEchoTool {
+    type Input = StreamingFailingEchoToolInput;
+
+    type Output = String;
+
+    const NAME: &'static str = "streaming_failing_echo";
+
+    fn kind() -> acp::ToolKind {
+        acp::ToolKind::Other
+    }
+
+    fn supports_input_streaming() -> bool {
+        true
+    }
+
+    fn initial_title(
+        &self,
+        _input: Result<Self::Input, serde_json::Value>,
+        _cx: &mut App,
+    ) -> SharedString {
+        "echo".into()
+    }
+
+    fn run(
+        self: Arc<Self>,
+        mut input: ToolInput<Self::Input>,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<Self::Output, Self::Output>> {
+        cx.spawn(async move |_cx| {
+            for _ in 0..self.receive_chunks_until_failure {
+                let _ = input.recv_partial().await;
+            }
+            Err("failed".into())
+        })
+    }
+}
+
 /// A tool that echoes its input
 #[derive(JsonSchema, Serialize, Deserialize)]
 pub struct EchoToolInput {

crates/agent/src/thread.rs 🔗

@@ -219,6 +219,7 @@ impl UserMessage {
             "<rules>\nThe user has specified the following rules that should be applied:\n";
         const OPEN_DIAGNOSTICS_TAG: &str = "<diagnostics>";
         const OPEN_DIFFS_TAG: &str = "<diffs>";
+        const MERGE_CONFLICT_TAG: &str = "<merge_conflicts>";
 
         let mut file_context = OPEN_FILES_TAG.to_string();
         let mut directory_context = OPEN_DIRECTORIES_TAG.to_string();
@@ -229,6 +230,7 @@ impl UserMessage {
         let mut rules_context = OPEN_RULES_TAG.to_string();
         let mut diagnostics_context = OPEN_DIAGNOSTICS_TAG.to_string();
         let mut diffs_context = OPEN_DIFFS_TAG.to_string();
+        let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string();
 
         for chunk in &self.content {
             let chunk = match chunk {
@@ -336,6 +338,18 @@ impl UserMessage {
                             )
                             .ok();
                         }
+                        MentionUri::MergeConflict { file_path } => {
+                            write!(
+                                &mut merge_conflict_context,
+                                "\nMerge conflict in {}:\n{}",
+                                file_path,
+                                MarkdownCodeBlock {
+                                    tag: "diff",
+                                    text: content
+                                }
+                            )
+                            .ok();
+                        }
                     }
 
                     language_model::MessageContent::Text(uri.as_link().to_string())
@@ -410,6 +424,13 @@ impl UserMessage {
                 .push(language_model::MessageContent::Text(diagnostics_context));
         }
 
+        if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() {
+            merge_conflict_context.push_str("</merge_conflicts>\n");
+            message
+                .content
+                .push(language_model::MessageContent::Text(merge_conflict_context));
+        }
+
         if message.content.len() > len_before_context {
             message.content.insert(
                 len_before_context,
@@ -1846,12 +1867,37 @@ impl Thread {
                 Ok(events) => (events.fuse(), None),
                 Err(err) => (stream::empty().boxed().fuse(), Some(err)),
             };
-            let mut tool_results = FuturesUnordered::new();
+            let mut tool_results: FuturesUnordered<Task<LanguageModelToolResult>> =
+                FuturesUnordered::new();
+            let mut early_tool_results: Vec<LanguageModelToolResult> = Vec::new();
             let mut cancelled = false;
             loop {
-                // Race between getting the first event and cancellation
+                // Race between getting the first event, tool completion, and cancellation.
                 let first_event = futures::select! {
                     event = events.next().fuse() => event,
+                    tool_result = futures::StreamExt::select_next_some(&mut tool_results) => {
+                        let is_error = tool_result.is_error;
+                        let is_still_streaming = this
+                            .read_with(cx, |this, _cx| {
+                                this.running_turn
+                                    .as_ref()
+                                    .and_then(|turn| turn.streaming_tool_inputs.get(&tool_result.tool_use_id))
+                                    .map_or(false, |inputs| !inputs.has_received_final())
+                            })
+                            .unwrap_or(false);
+
+                        early_tool_results.push(tool_result);
+
+                        // Only break if the tool errored and we are still
+                        // streaming the input of the tool. If the tool errored
+                        // but we are no longer streaming its input (i.e. there
+                        // are parallel tool calls) we want to continue
+                        // processing those tool inputs.
+                        if is_error && is_still_streaming {
+                            break;
+                        }
+                        continue;
+                    }
                     _ = cancellation_rx.changed().fuse() => {
                         if *cancellation_rx.borrow() {
                             cancelled = true;
@@ -1931,26 +1977,13 @@ impl Thread {
                 }
             })?;
 
-            let end_turn = tool_results.is_empty();
-            while let Some(tool_result) = tool_results.next().await {
-                log::debug!("Tool finished {:?}", tool_result);
+            let end_turn = tool_results.is_empty() && early_tool_results.is_empty();
 
-                event_stream.update_tool_call_fields(
-                    &tool_result.tool_use_id,
-                    acp::ToolCallUpdateFields::new()
-                        .status(if tool_result.is_error {
-                            acp::ToolCallStatus::Failed
-                        } else {
-                            acp::ToolCallStatus::Completed
-                        })
-                        .raw_output(tool_result.output.clone()),
-                    None,
-                );
-                this.update(cx, |this, _cx| {
-                    this.pending_message()
-                        .tool_results
-                        .insert(tool_result.tool_use_id.clone(), tool_result);
-                })?;
+            for tool_result in early_tool_results {
+                Self::process_tool_result(this, event_stream, cx, tool_result)?;
+            }
+            while let Some(tool_result) = tool_results.next().await {
+                Self::process_tool_result(this, event_stream, cx, tool_result)?;
             }
 
             this.update(cx, |this, cx| {
@@ -2004,6 +2037,33 @@ impl Thread {
         }
     }
 
+    fn process_tool_result(
+        this: &WeakEntity<Thread>,
+        event_stream: &ThreadEventStream,
+        cx: &mut AsyncApp,
+        tool_result: LanguageModelToolResult,
+    ) -> Result<(), anyhow::Error> {
+        log::debug!("Tool finished {:?}", tool_result);
+
+        event_stream.update_tool_call_fields(
+            &tool_result.tool_use_id,
+            acp::ToolCallUpdateFields::new()
+                .status(if tool_result.is_error {
+                    acp::ToolCallStatus::Failed
+                } else {
+                    acp::ToolCallStatus::Completed
+                })
+                .raw_output(tool_result.output.clone()),
+            None,
+        );
+        this.update(cx, |this, _cx| {
+            this.pending_message()
+                .tool_results
+                .insert(tool_result.tool_use_id.clone(), tool_result);
+        })?;
+        Ok(())
+    }
+
     fn handle_completion_error(
         &mut self,
         error: LanguageModelCompletionError,
@@ -3072,6 +3132,10 @@ impl ToolInputSender {
         (sender, input)
     }
 
+    pub(crate) fn has_received_final(&self) -> bool {
+        self.final_tx.is_none()
+    }
+
     pub(crate) fn send_partial(&self, value: serde_json::Value) {
         self.partial_tx.unbounded_send(value).ok();
     }

crates/agent/src/thread_store.rs 🔗

@@ -2,6 +2,7 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase};
 use agent_client_protocol as acp;
 use anyhow::{Result, anyhow};
 use gpui::{App, Context, Entity, Global, Task, prelude::*};
+use std::collections::HashMap;
 use util::path_list::PathList;
 
 struct GlobalThreadStore(Entity<ThreadStore>);
@@ -10,6 +11,7 @@ impl Global for GlobalThreadStore {}
 
 pub struct ThreadStore {
     threads: Vec<DbThreadMetadata>,
+    threads_by_paths: HashMap<PathList, Vec<usize>>,
 }
 
 impl ThreadStore {
@@ -29,6 +31,7 @@ impl ThreadStore {
     pub fn new(cx: &mut Context<Self>) -> Self {
         let this = Self {
             threads: Vec::new(),
+            threads_by_paths: HashMap::default(),
         };
         this.reload(cx);
         this
@@ -91,14 +94,21 @@ impl ThreadStore {
         let database_connection = ThreadsDatabase::connect(cx);
         cx.spawn(async move |this, cx| {
             let database = database_connection.await.map_err(|err| anyhow!(err))?;
-            let threads = database
-                .list_threads()
-                .await?
-                .into_iter()
-                .filter(|thread| thread.parent_session_id.is_none())
-                .collect::<Vec<_>>();
+            let all_threads = database.list_threads().await?;
             this.update(cx, |this, cx| {
-                this.threads = threads;
+                this.threads.clear();
+                this.threads_by_paths.clear();
+                for thread in all_threads {
+                    if thread.parent_session_id.is_some() {
+                        continue;
+                    }
+                    let index = this.threads.len();
+                    this.threads_by_paths
+                        .entry(thread.folder_paths.clone())
+                        .or_default()
+                        .push(index);
+                    this.threads.push(thread);
+                }
                 cx.notify();
             })
         })
@@ -114,10 +124,12 @@ impl ThreadStore {
     }
 
     /// Returns threads whose folder_paths match the given paths exactly.
+    /// Uses a cached index for O(1) lookup per path list.
     pub fn threads_for_paths(&self, paths: &PathList) -> impl Iterator<Item = &DbThreadMetadata> {
-        self.threads
-            .iter()
-            .filter(move |thread| &thread.folder_paths == paths)
+        self.threads_by_paths
+            .get(paths)
+            .into_iter()
+            .flat_map(|indices| indices.iter().map(|&index| &self.threads[index]))
     }
 }
 

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

@@ -108,12 +108,17 @@ pub enum StreamingEditFileMode {
 pub struct Edit {
     /// The exact text to find in the file. This will be matched using fuzzy matching
     /// to handle minor differences in whitespace or formatting.
+    ///
+    /// Always include complete lines. Do not start or end mid-line.
+    /// Be minimal with replacements:
+    /// - For unique lines, include only those lines
+    /// - For non-unique lines, include enough context to identify them
     pub old_text: String,
     /// The text to replace it with
     pub new_text: String,
 }
 
-#[derive(Default, Debug, Deserialize)]
+#[derive(Clone, Default, Debug, Deserialize)]
 struct StreamingEditFileToolPartialInput {
     #[serde(default)]
     display_description: Option<String>,
@@ -127,7 +132,7 @@ struct StreamingEditFileToolPartialInput {
     edits: Option<Vec<PartialEdit>>,
 }
 
-#[derive(Default, Debug, Deserialize)]
+#[derive(Clone, Default, Debug, Deserialize)]
 pub struct PartialEdit {
     #[serde(default)]
     pub old_text: Option<String>,
@@ -309,12 +314,19 @@ impl AgentTool for StreamingEditFileTool {
     ) -> Task<Result<Self::Output, Self::Output>> {
         cx.spawn(async move |cx: &mut AsyncApp| {
             let mut state: Option<EditSession> = None;
+            let mut last_partial: Option<StreamingEditFileToolPartialInput> = None;
             loop {
                 futures::select! {
                     partial = input.recv_partial().fuse() => {
                         let Some(partial_value) = partial else { break };
                         if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) {
+                            let path_complete = parsed.path.is_some()
+                                && parsed.path.as_ref() == last_partial.as_ref().and_then(|p| p.path.as_ref());
+
+                            last_partial = Some(parsed.clone());
+
                             if state.is_none()
+                                && path_complete
                                 && let StreamingEditFileToolPartialInput {
                                     path: Some(path),
                                     display_description: Some(display_description),
@@ -1902,6 +1914,13 @@ mod tests {
         let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
 
         // Setup + single edit that stays in-progress (no second edit to prove completion)
+        sender.send_partial(json!({
+            "display_description": "Single edit",
+            "path": "root/file.txt",
+            "mode": "edit",
+        }));
+        cx.run_until_parked();
+
         sender.send_partial(json!({
             "display_description": "Single edit",
             "path": "root/file.txt",
@@ -3470,6 +3489,12 @@ mod tests {
         let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
 
         // Transition to BufferResolved
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "path": "root/file.txt",
+        }));
+        cx.run_until_parked();
+
         sender.send_partial(json!({
             "display_description": "Overwrite file",
             "path": "root/file.txt",
@@ -3545,8 +3570,9 @@ mod tests {
         // Verify buffer still has old content (no content partial yet)
         let buffer = project.update(cx, |project, cx| {
             let path = project.find_project_path("root/file.txt", cx).unwrap();
-            project.get_open_buffer(&path, cx).unwrap()
+            project.open_buffer(path, cx)
         });
+        let buffer = buffer.await.unwrap();
         assert_eq!(
             buffer.read_with(cx, |b, _| b.text()),
             "old line 1\nold line 2\nold line 3\n"
@@ -3730,6 +3756,106 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode(
+        cx: &mut TestAppContext,
+    ) {
+        let (tool, _project, _action_log, _fs, _thread) =
+            setup_test(cx, json!({"file.txt": "old_content"})).await;
+        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
+        let (event_stream, _receiver) = ToolCallEventStream::test();
+        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "write"
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "write",
+            "content": "new_content"
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "write",
+            "content": "new_content",
+            "path": "root"
+        }));
+        cx.run_until_parked();
+
+        // Send final.
+        sender.send_final(json!({
+            "display_description": "Overwrite file",
+            "mode": "write",
+            "content": "new_content",
+            "path": "root/file.txt"
+        }));
+
+        let result = task.await;
+        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
+            panic!("expected success");
+        };
+        assert_eq!(new_text, "new_content");
+    }
+
+    #[gpui::test]
+    async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode(
+        cx: &mut TestAppContext,
+    ) {
+        let (tool, _project, _action_log, _fs, _thread) =
+            setup_test(cx, json!({"file.txt": "old_content"})).await;
+        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
+        let (event_stream, _receiver) = ToolCallEventStream::test();
+        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit"
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content"}]
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content", "new_text": "new_content"}]
+        }));
+        cx.run_until_parked();
+
+        sender.send_partial(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
+            "path": "root"
+        }));
+        cx.run_until_parked();
+
+        // Send final.
+        sender.send_final(json!({
+            "display_description": "Overwrite file",
+            "mode": "edit",
+            "edits": [{"old_text": "old_content", "new_text": "new_content"}],
+            "path": "root/file.txt"
+        }));
+        cx.run_until_parked();
+
+        let result = task.await;
+        let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
+            panic!("expected success");
+        };
+        assert_eq!(new_text, "new_content");
+    }
+
     async fn setup_test_with_fs(
         cx: &mut TestAppContext,
         fs: Arc<project::FakeFs>,

crates/agent_servers/Cargo.toml 🔗

@@ -61,7 +61,7 @@ nix.workspace = true
 client = { workspace = true, features = ["test-support"] }
 env_logger.workspace = true
 fs.workspace = true
-language.workspace = true
+
 indoc.workspace = true
 acp_thread = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }

crates/agent_servers/src/acp.rs 🔗

@@ -131,6 +131,7 @@ impl AgentSessionList for AcpSessionList {
                                 .ok()
                                 .map(|dt| dt.with_timezone(&chrono::Utc))
                         }),
+                        created_at: None,
                         meta: s.meta,
                     })
                     .collect(),
@@ -278,7 +279,7 @@ impl AcpConnection {
                 acp::InitializeRequest::new(acp::ProtocolVersion::V1)
                     .client_capabilities(
                         acp::ClientCapabilities::new()
-                            .fs(acp::FileSystemCapability::new()
+                            .fs(acp::FileSystemCapabilities::new()
                                 .read_text_file(true)
                                 .write_text_file(true))
                             .terminal(true)
@@ -330,11 +331,11 @@ impl AcpConnection {
                 "env": command.env.clone().unwrap_or_default(),
             });
             let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]);
-            vec![
-                acp::AuthMethod::new("spawn-gemini-cli", "Login")
+            vec![acp::AuthMethod::Agent(
+                acp::AuthMethodAgent::new("spawn-gemini-cli", "Login")
                     .description("Login with your Google or Vertex AI account")
                     .meta(meta),
-            ]
+            )]
         } else {
             response.auth_methods
         };
@@ -385,7 +386,7 @@ impl AgentConnection for AcpConnection {
 
         cx.spawn(async move |cx| {
             let response = self.connection
-                .new_session(acp::NewSessionRequest::new(cwd).mcp_servers(mcp_servers))
+                .new_session(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers))
                 .await
                 .map_err(map_acp_error)?;
 
@@ -560,6 +561,7 @@ impl AgentConnection for AcpConnection {
                 AcpThread::new(
                     None,
                     self.display_name.clone(),
+                    Some(cwd),
                     self.clone(),
                     project,
                     action_log,
@@ -598,9 +600,10 @@ impl AgentConnection for AcpConnection {
 
     fn load_session(
         self: Rc<Self>,
-        session: AgentSessionInfo,
+        session_id: acp::SessionId,
         project: Entity<Project>,
         cwd: &Path,
+        title: Option<SharedString>,
         cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
         if !self.agent_capabilities.load_session {
@@ -612,25 +615,23 @@ impl AgentConnection for AcpConnection {
         let cwd = cwd.to_path_buf();
         let mcp_servers = mcp_servers_for_project(&project, cx);
         let action_log = cx.new(|_| ActionLog::new(project.clone()));
-        let title = session
-            .title
-            .clone()
-            .unwrap_or_else(|| self.display_name.clone());
+        let title = title.unwrap_or_else(|| self.display_name.clone());
         let thread: Entity<AcpThread> = cx.new(|cx| {
             AcpThread::new(
                 None,
                 title,
+                Some(cwd.clone()),
                 self.clone(),
                 project,
                 action_log,
-                session.session_id.clone(),
+                session_id.clone(),
                 watch::Receiver::constant(self.agent_capabilities.prompt_capabilities.clone()),
                 cx,
             )
         });
 
         self.sessions.borrow_mut().insert(
-            session.session_id.clone(),
+            session_id.clone(),
             AcpSession {
                 thread: thread.downgrade(),
                 suppress_abort_err: false,
@@ -644,21 +645,20 @@ impl AgentConnection for AcpConnection {
             let response = match self
                 .connection
                 .load_session(
-                    acp::LoadSessionRequest::new(session.session_id.clone(), cwd)
-                        .mcp_servers(mcp_servers),
+                    acp::LoadSessionRequest::new(session_id.clone(), cwd).mcp_servers(mcp_servers),
                 )
                 .await
             {
                 Ok(response) => response,
                 Err(err) => {
-                    self.sessions.borrow_mut().remove(&session.session_id);
+                    self.sessions.borrow_mut().remove(&session_id);
                     return Err(map_acp_error(err));
                 }
             };
 
             let (modes, models, config_options) =
                 config_state(response.modes, response.models, response.config_options);
-            if let Some(session) = self.sessions.borrow_mut().get_mut(&session.session_id) {
+            if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) {
                 session.session_modes = modes;
                 session.models = models;
                 session.config_options = config_options.map(ConfigOptions::new);
@@ -670,9 +670,10 @@ impl AgentConnection for AcpConnection {
 
     fn resume_session(
         self: Rc<Self>,
-        session: AgentSessionInfo,
+        session_id: acp::SessionId,
         project: Entity<Project>,
         cwd: &Path,
+        title: Option<SharedString>,
         cx: &mut App,
     ) -> Task<Result<Entity<AcpThread>>> {
         if self
@@ -689,25 +690,23 @@ impl AgentConnection for AcpConnection {
         let cwd = cwd.to_path_buf();
         let mcp_servers = mcp_servers_for_project(&project, cx);
         let action_log = cx.new(|_| ActionLog::new(project.clone()));
-        let title = session
-            .title
-            .clone()
-            .unwrap_or_else(|| self.display_name.clone());
+        let title = title.unwrap_or_else(|| self.display_name.clone());
         let thread: Entity<AcpThread> = cx.new(|cx| {
             AcpThread::new(
                 None,
                 title,
+                Some(cwd.clone()),
                 self.clone(),
                 project,
                 action_log,
-                session.session_id.clone(),
+                session_id.clone(),
                 watch::Receiver::constant(self.agent_capabilities.prompt_capabilities.clone()),
                 cx,
             )
         });
 
         self.sessions.borrow_mut().insert(
-            session.session_id.clone(),
+            session_id.clone(),
             AcpSession {
                 thread: thread.downgrade(),
                 suppress_abort_err: false,
@@ -721,21 +720,21 @@ impl AgentConnection for AcpConnection {
             let response = match self
                 .connection
                 .resume_session(
-                    acp::ResumeSessionRequest::new(session.session_id.clone(), cwd)
+                    acp::ResumeSessionRequest::new(session_id.clone(), cwd)
                         .mcp_servers(mcp_servers),
                 )
                 .await
             {
                 Ok(response) => response,
                 Err(err) => {
-                    self.sessions.borrow_mut().remove(&session.session_id);
+                    self.sessions.borrow_mut().remove(&session_id);
                     return Err(map_acp_error(err));
                 }
             };
 
             let (modes, models, config_options) =
                 config_state(response.modes, response.models, response.config_options);
-            if let Some(session) = self.sessions.borrow_mut().get_mut(&session.session_id) {
+            if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) {
                 session.session_modes = modes;
                 session.models = models;
                 session.config_options = config_options.map(ConfigOptions::new);
@@ -745,6 +744,31 @@ impl AgentConnection for AcpConnection {
         })
     }
 
+    fn supports_close_session(&self) -> bool {
+        self.agent_capabilities.session_capabilities.close.is_some()
+    }
+
+    fn close_session(
+        self: Rc<Self>,
+        session_id: &acp::SessionId,
+        cx: &mut App,
+    ) -> Task<Result<()>> {
+        if !self.supports_close_session() {
+            return Task::ready(Err(anyhow!(LoadError::Other(
+                "Closing sessions is not supported by this agent.".into()
+            ))));
+        }
+
+        let conn = self.connection.clone();
+        let session_id = session_id.clone();
+        cx.foreground_executor().spawn(async move {
+            conn.close_session(acp::CloseSessionRequest::new(session_id.clone()))
+                .await?;
+            self.sessions.borrow_mut().remove(&session_id);
+            Ok(())
+        })
+    }
+
     fn auth_methods(&self) -> &[acp::AuthMethod] {
         &self.auth_methods
     }
@@ -1374,10 +1398,10 @@ impl acp::Client for ClientDelegate {
         Ok(acp::CreateTerminalResponse::new(terminal_id))
     }
 
-    async fn kill_terminal_command(
+    async fn kill_terminal(
         &self,
-        args: acp::KillTerminalCommandRequest,
-    ) -> Result<acp::KillTerminalCommandResponse, acp::Error> {
+        args: acp::KillTerminalRequest,
+    ) -> Result<acp::KillTerminalResponse, acp::Error> {
         self.session_thread(&args.session_id)?
             .update(&mut self.cx.clone(), |thread, cx| {
                 thread.kill_terminal(args.terminal_id, cx)

crates/agent_servers/src/agent_servers.rs 🔗

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

crates/agent_servers/src/custom.rs 🔗

@@ -84,19 +84,12 @@ impl AgentServer for CustomAgentServer {
         let config_id = config_id.to_string();
         let value_id = value_id.to_string();
 
-        update_settings_file(fs, cx, move |settings, _| {
+        update_settings_file(fs, cx, move |settings, cx| {
             let settings = settings
                 .agent_servers
                 .get_or_insert_default()
                 .entry(name.to_string())
-                .or_insert_with(|| settings::CustomAgentServerSettings::Extension {
-                    default_model: None,
-                    default_mode: None,
-                    env: Default::default(),
-                    favorite_models: Vec::new(),
-                    default_config_options: Default::default(),
-                    favorite_config_option_values: Default::default(),
-                });
+                .or_insert_with(|| default_settings_for_agent(&name, cx));
 
             match settings {
                 settings::CustomAgentServerSettings::Custom {
@@ -132,19 +125,12 @@ impl AgentServer for CustomAgentServer {
 
     fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
         let name = self.name();
-        update_settings_file(fs, cx, move |settings, _| {
+        update_settings_file(fs, cx, move |settings, cx| {
             let settings = settings
                 .agent_servers
                 .get_or_insert_default()
                 .entry(name.to_string())
-                .or_insert_with(|| settings::CustomAgentServerSettings::Extension {
-                    default_model: None,
-                    default_mode: None,
-                    env: Default::default(),
-                    favorite_models: Vec::new(),
-                    default_config_options: Default::default(),
-                    favorite_config_option_values: Default::default(),
-                });
+                .or_insert_with(|| default_settings_for_agent(&name, cx));
 
             match settings {
                 settings::CustomAgentServerSettings::Custom { default_mode, .. }
@@ -171,19 +157,12 @@ impl AgentServer for CustomAgentServer {
 
     fn set_default_model(&self, model_id: Option<acp::ModelId>, fs: Arc<dyn Fs>, cx: &mut App) {
         let name = self.name();
-        update_settings_file(fs, cx, move |settings, _| {
+        update_settings_file(fs, cx, move |settings, cx| {
             let settings = settings
                 .agent_servers
                 .get_or_insert_default()
                 .entry(name.to_string())
-                .or_insert_with(|| settings::CustomAgentServerSettings::Extension {
-                    default_model: None,
-                    default_mode: None,
-                    env: Default::default(),
-                    favorite_models: Vec::new(),
-                    default_config_options: Default::default(),
-                    favorite_config_option_values: Default::default(),
-                });
+                .or_insert_with(|| default_settings_for_agent(&name, cx));
 
             match settings {
                 settings::CustomAgentServerSettings::Custom { default_model, .. }
@@ -222,19 +201,12 @@ impl AgentServer for CustomAgentServer {
         cx: &App,
     ) {
         let name = self.name();
-        update_settings_file(fs, cx, move |settings, _| {
+        update_settings_file(fs, cx, move |settings, cx| {
             let settings = settings
                 .agent_servers
                 .get_or_insert_default()
                 .entry(name.to_string())
-                .or_insert_with(|| settings::CustomAgentServerSettings::Extension {
-                    default_model: None,
-                    default_mode: None,
-                    env: Default::default(),
-                    favorite_models: Vec::new(),
-                    default_config_options: Default::default(),
-                    favorite_config_option_values: Default::default(),
-                });
+                .or_insert_with(|| default_settings_for_agent(&name, cx));
 
             let favorite_models = match settings {
                 settings::CustomAgentServerSettings::Custom {
@@ -282,19 +254,12 @@ impl AgentServer for CustomAgentServer {
         let name = self.name();
         let config_id = config_id.to_string();
         let value_id = value_id.map(|s| s.to_string());
-        update_settings_file(fs, cx, move |settings, _| {
+        update_settings_file(fs, cx, move |settings, cx| {
             let settings = settings
                 .agent_servers
                 .get_or_insert_default()
                 .entry(name.to_string())
-                .or_insert_with(|| settings::CustomAgentServerSettings::Extension {
-                    default_model: None,
-                    default_mode: None,
-                    env: Default::default(),
-                    favorite_models: Vec::new(),
-                    default_config_options: Default::default(),
-                    favorite_config_option_values: Default::default(),
-                });
+                .or_insert_with(|| default_settings_for_agent(&name, cx));
 
             match settings {
                 settings::CustomAgentServerSettings::Custom {
@@ -332,45 +297,27 @@ impl AgentServer for CustomAgentServer {
             .unwrap_or_else(|| name.clone());
         let default_mode = self.default_mode(cx);
         let default_model = self.default_model(cx);
-        let is_previous_built_in =
-            matches!(name.as_ref(), CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME);
-        let (default_config_options, is_registry_agent) =
-            cx.read_global(|settings: &SettingsStore, _| {
-                let agent_settings = settings
-                    .get::<AllAgentServersSettings>(None)
-                    .get(self.name().as_ref());
-
-                let is_registry = agent_settings
-                    .map(|s| {
-                        matches!(
-                            s,
-                            project::agent_server_store::CustomAgentServerSettings::Registry { .. }
-                        )
-                    })
-                    .unwrap_or(false);
-
-                let config_options = agent_settings
-                    .map(|s| match s {
-                        project::agent_server_store::CustomAgentServerSettings::Custom {
-                            default_config_options,
-                            ..
-                        }
-                        | project::agent_server_store::CustomAgentServerSettings::Extension {
-                            default_config_options,
-                            ..
-                        }
-                        | project::agent_server_store::CustomAgentServerSettings::Registry {
-                            default_config_options,
-                            ..
-                        } => default_config_options.clone(),
-                    })
-                    .unwrap_or_default();
-
-                (config_options, is_registry)
-            });
-
-        // Intermediate step to allow for previous built-ins to also be triggered if they aren't in settings yet.
-        let is_registry_agent = is_registry_agent || is_previous_built_in;
+        let is_registry_agent = is_registry_agent(&name, cx);
+        let default_config_options = cx.read_global(|settings: &SettingsStore, _| {
+            settings
+                .get::<AllAgentServersSettings>(None)
+                .get(self.name().as_ref())
+                .map(|s| match s {
+                    project::agent_server_store::CustomAgentServerSettings::Custom {
+                        default_config_options,
+                        ..
+                    }
+                    | project::agent_server_store::CustomAgentServerSettings::Extension {
+                        default_config_options,
+                        ..
+                    }
+                    | project::agent_server_store::CustomAgentServerSettings::Registry {
+                        default_config_options,
+                        ..
+                    } => default_config_options.clone(),
+                })
+                .unwrap_or_default()
+        });
 
         if is_registry_agent {
             if let Some(registry_store) = project::AgentRegistryStore::try_global(cx) {
@@ -417,7 +364,6 @@ impl AgentServer for CustomAgentServer {
                         })?;
                     anyhow::Ok(agent.get_command(
                         extra_env,
-                        delegate.status_tx,
                         delegate.new_version_available,
                         &mut cx.to_async(),
                     ))
@@ -458,3 +404,222 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task<Result<String>> {
         )
     })
 }
+
+fn is_registry_agent(name: &str, cx: &App) -> bool {
+    let is_previous_built_in = matches!(name, CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME);
+    let is_in_registry = project::AgentRegistryStore::try_global(cx)
+        .map(|store| store.read(cx).agent(name).is_some())
+        .unwrap_or(false);
+    let is_settings_registry = cx.read_global(|settings: &SettingsStore, _| {
+        settings
+            .get::<AllAgentServersSettings>(None)
+            .get(name)
+            .is_some_and(|s| {
+                matches!(
+                    s,
+                    project::agent_server_store::CustomAgentServerSettings::Registry { .. }
+                )
+            })
+    });
+    is_previous_built_in || is_in_registry || is_settings_registry
+}
+
+fn default_settings_for_agent(name: &str, cx: &App) -> settings::CustomAgentServerSettings {
+    if is_registry_agent(name, cx) {
+        settings::CustomAgentServerSettings::Registry {
+            default_model: None,
+            default_mode: None,
+            env: Default::default(),
+            favorite_models: Vec::new(),
+            default_config_options: Default::default(),
+            favorite_config_option_values: Default::default(),
+        }
+    } else {
+        settings::CustomAgentServerSettings::Extension {
+            default_model: None,
+            default_mode: None,
+            env: Default::default(),
+            favorite_models: Vec::new(),
+            default_config_options: Default::default(),
+            favorite_config_option_values: Default::default(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use collections::HashMap;
+    use gpui::TestAppContext;
+    use project::agent_registry_store::{
+        AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent,
+    };
+    use settings::Settings as _;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+    }
+
+    fn init_registry_with_agents(cx: &mut TestAppContext, agent_ids: &[&str]) {
+        let agents: Vec<RegistryAgent> = agent_ids
+            .iter()
+            .map(|id| {
+                let id = SharedString::from(id.to_string());
+                RegistryAgent::Npx(RegistryNpxAgent {
+                    metadata: RegistryAgentMetadata {
+                        id: id.clone(),
+                        name: id.clone(),
+                        description: SharedString::from(""),
+                        version: SharedString::from("1.0.0"),
+                        repository: None,
+                        icon_path: None,
+                    },
+                    package: id,
+                    args: Vec::new(),
+                    env: HashMap::default(),
+                })
+            })
+            .collect();
+        cx.update(|cx| {
+            AgentRegistryStore::init_test_global(cx, agents);
+        });
+    }
+
+    fn set_agent_server_settings(
+        cx: &mut TestAppContext,
+        entries: Vec<(&str, settings::CustomAgentServerSettings)>,
+    ) {
+        cx.update(|cx| {
+            AllAgentServersSettings::override_global(
+                project::agent_server_store::AllAgentServersSettings(
+                    entries
+                        .into_iter()
+                        .map(|(name, settings)| (name.to_string(), settings.into()))
+                        .collect(),
+                ),
+                cx,
+            );
+        });
+    }
+
+    #[gpui::test]
+    fn test_previous_builtins_are_registry(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            assert!(is_registry_agent(CLAUDE_AGENT_NAME, cx));
+            assert!(is_registry_agent(CODEX_NAME, cx));
+            assert!(is_registry_agent(GEMINI_NAME, cx));
+        });
+    }
+
+    #[gpui::test]
+    fn test_unknown_agent_is_not_registry(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            assert!(!is_registry_agent("my-custom-agent", cx));
+        });
+    }
+
+    #[gpui::test]
+    fn test_agent_in_registry_store_is_registry(cx: &mut TestAppContext) {
+        init_test(cx);
+        init_registry_with_agents(cx, &["some-new-registry-agent"]);
+        cx.update(|cx| {
+            assert!(is_registry_agent("some-new-registry-agent", cx));
+            assert!(!is_registry_agent("not-in-registry", cx));
+        });
+    }
+
+    #[gpui::test]
+    fn test_agent_with_registry_settings_type_is_registry(cx: &mut TestAppContext) {
+        init_test(cx);
+        set_agent_server_settings(
+            cx,
+            vec![(
+                "agent-from-settings",
+                settings::CustomAgentServerSettings::Registry {
+                    env: HashMap::default(),
+                    default_mode: None,
+                    default_model: None,
+                    favorite_models: Vec::new(),
+                    default_config_options: HashMap::default(),
+                    favorite_config_option_values: HashMap::default(),
+                },
+            )],
+        );
+        cx.update(|cx| {
+            assert!(is_registry_agent("agent-from-settings", cx));
+        });
+    }
+
+    #[gpui::test]
+    fn test_agent_with_extension_settings_type_is_not_registry(cx: &mut TestAppContext) {
+        init_test(cx);
+        set_agent_server_settings(
+            cx,
+            vec![(
+                "my-extension-agent",
+                settings::CustomAgentServerSettings::Extension {
+                    env: HashMap::default(),
+                    default_mode: None,
+                    default_model: None,
+                    favorite_models: Vec::new(),
+                    default_config_options: HashMap::default(),
+                    favorite_config_option_values: HashMap::default(),
+                },
+            )],
+        );
+        cx.update(|cx| {
+            assert!(!is_registry_agent("my-extension-agent", cx));
+        });
+    }
+
+    #[gpui::test]
+    fn test_default_settings_for_builtin_agent(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            assert!(matches!(
+                default_settings_for_agent(CODEX_NAME, cx),
+                settings::CustomAgentServerSettings::Registry { .. }
+            ));
+            assert!(matches!(
+                default_settings_for_agent(CLAUDE_AGENT_NAME, cx),
+                settings::CustomAgentServerSettings::Registry { .. }
+            ));
+            assert!(matches!(
+                default_settings_for_agent(GEMINI_NAME, cx),
+                settings::CustomAgentServerSettings::Registry { .. }
+            ));
+        });
+    }
+
+    #[gpui::test]
+    fn test_default_settings_for_extension_agent(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            assert!(matches!(
+                default_settings_for_agent("some-extension-agent", cx),
+                settings::CustomAgentServerSettings::Extension { .. }
+            ));
+        });
+    }
+
+    #[gpui::test]
+    fn test_default_settings_for_agent_in_registry(cx: &mut TestAppContext) {
+        init_test(cx);
+        init_registry_with_agents(cx, &["new-registry-agent"]);
+        cx.update(|cx| {
+            assert!(matches!(
+                default_settings_for_agent("new-registry-agent", cx),
+                settings::CustomAgentServerSettings::Registry { .. }
+            ));
+            assert!(matches!(
+                default_settings_for_agent("not-in-registry", cx),
+                settings::CustomAgentServerSettings::Extension { .. }
+            ));
+        });
+    }
+}

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -2,6 +2,7 @@ use crate::{AgentServer, AgentServerDelegate};
 use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
 use agent_client_protocol as acp;
 use futures::{FutureExt, StreamExt, channel::mpsc, select};
+use gpui::AppContext;
 use gpui::{Entity, TestAppContext};
 use indoc::indoc;
 use project::{FakeFs, Project};
@@ -408,7 +409,8 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
         let http_client = reqwest_client::ReqwestClient::user_agent("agent tests").unwrap();
         cx.set_http_client(Arc::new(http_client));
         let client = client::Client::production(cx);
-        language_model::init(client, cx);
+        let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
+        language_model::init(user_store, client, cx);
 
         #[cfg(test)]
         project::agent_server_store::AllAgentServersSettings::override_global(
@@ -429,7 +431,7 @@ pub async fn new_test_thread(
     cx: &mut TestAppContext,
 ) -> Entity<AcpThread> {
     let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
-    let delegate = AgentServerDelegate::new(store, project.clone(), None, None);
+    let delegate = AgentServerDelegate::new(store, None);
 
     let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap();
 

crates/agent_settings/Cargo.toml 🔗

@@ -30,7 +30,7 @@ util.workspace = true
 [dev-dependencies]
 fs.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
-paths.workspace = true
+
 serde_json_lenient.workspace = true
 serde_json.workspace = true
 settings = { workspace = true, features = ["test-support"] }

crates/agent_settings/src/agent_settings.rs 🔗

@@ -12,7 +12,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
     DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection,
-    NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
+    NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
 };
 
 pub use crate::agent_profile::*;
@@ -51,6 +51,7 @@ pub struct AgentSettings {
     pub message_editor_min_lines: usize,
     pub show_turn_stats: bool,
     pub tool_permissions: ToolPermissions,
+    pub new_thread_location: NewThreadLocation,
 }
 
 impl AgentSettings {
@@ -438,6 +439,7 @@ impl Settings for AgentSettings {
             message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
             show_turn_stats: agent.show_turn_stats.unwrap(),
             tool_permissions: compile_tool_permissions(agent.tool_permissions),
+            new_thread_location: agent.new_thread_location.unwrap_or_default(),
         }
     }
 }

crates/agent_ui/Cargo.toml 🔗

@@ -121,7 +121,7 @@ acp_thread = { workspace = true, features = ["test-support"] }
 agent = { workspace = true, features = ["test-support"] }
 assistant_text_thread = { workspace = true, features = ["test-support"] }
 buffer_diff = { workspace = true, features = ["test-support"] }
-clock.workspace = true
+
 db = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 eval_utils.workspace = true
@@ -132,11 +132,8 @@ languages = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, "features" = ["test-support"] }
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
-recent_projects = { workspace = true, features = ["test-support"] }
-remote_connection = { workspace = true, features = ["test-support"] }
-title_bar = { workspace = true, features = ["test-support"] }
 semver.workspace = true
 reqwest_client.workspace = true
-tempfile.workspace = true
+
 tree-sitter-md.workspace = true
 unindent.workspace = true

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -228,6 +228,7 @@ impl AgentConfiguration {
             .unwrap_or(false);
 
         v_flex()
+            .min_w_0()
             .w_full()
             .when(is_expanded, |this| this.mb_2())
             .child(
@@ -312,6 +313,7 @@ impl AgentConfiguration {
             )
             .child(
                 v_flex()
+                    .min_w_0()
                     .w_full()
                     .px_2()
                     .gap_1()
@@ -330,10 +332,11 @@ impl AgentConfiguration {
                             .full_width()
                             .style(ButtonStyle::Outlined)
                             .layer(ElevationIndex::ModalSurface)
-                            .icon_position(IconPosition::Start)
-                            .icon(IconName::Thread)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
+                            .start_icon(
+                                Icon::new(IconName::Thread)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .label_size(LabelSize::Small)
                             .on_click(cx.listener({
                                 let provider = provider.clone();
@@ -355,10 +358,11 @@ impl AgentConfiguration {
                                 )
                                 .full_width()
                                 .style(ButtonStyle::Outlined)
-                                .icon_position(IconPosition::Start)
-                                .icon(IconName::Trash)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
+                                .start_icon(
+                                    Icon::new(IconName::Trash)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .label_size(LabelSize::Small)
                                 .on_click(cx.listener({
                                     let provider = provider.clone();
@@ -424,10 +428,11 @@ impl AgentConfiguration {
             .trigger(
                 Button::new("add-provider", "Add Provider")
                     .style(ButtonStyle::Outlined)
-                    .icon_position(IconPosition::Start)
-                    .icon(IconName::Plus)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .start_icon(
+                        Icon::new(IconName::Plus)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small),
             )
             .menu({
@@ -459,6 +464,7 @@ impl AgentConfiguration {
             });
 
         v_flex()
+            .min_w_0()
             .w_full()
             .child(self.render_section_title(
                 "LLM Providers",
@@ -498,6 +504,7 @@ impl AgentConfiguration {
                 Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
                 Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
                 Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
+                Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg),
                 Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg),
             };
 
@@ -521,10 +528,11 @@ impl AgentConfiguration {
             .trigger(
                 Button::new("add-server", "Add Server")
                     .style(ButtonStyle::Outlined)
-                    .icon_position(IconPosition::Start)
-                    .icon(IconName::Plus)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .start_icon(
+                        Icon::new(IconName::Plus)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small),
             )
             .menu({
@@ -559,6 +567,7 @@ impl AgentConfiguration {
             });
 
         v_flex()
+            .min_w_0()
             .border_b_1()
             .border_color(cx.theme().colors().border)
             .child(self.render_section_title(
@@ -802,9 +811,12 @@ impl AgentConfiguration {
             });
 
         v_flex()
+            .min_w_0()
             .id(item_id.clone())
             .child(
                 h_flex()
+                    .min_w_0()
+                    .w_full()
                     .justify_between()
                     .child(
                         h_flex()
@@ -820,13 +832,13 @@ impl AgentConfiguration {
                                     .tooltip(Tooltip::text(tooltip_text))
                                     .child(status_indicator),
                             )
-                            .child(Label::new(item_id).truncate())
+                            .child(Label::new(item_id).flex_shrink_0().truncate())
                             .child(
                                 div()
                                     .id("extension-source")
+                                    .min_w_0()
                                     .mt_0p5()
                                     .mx_1()
-                                    .flex_none()
                                     .tooltip(Tooltip::text(source_tooltip))
                                     .child(
                                         Icon::new(source_icon)
@@ -962,10 +974,11 @@ impl AgentConfiguration {
             .trigger(
                 Button::new("add-agent", "Add Agent")
                     .style(ButtonStyle::Outlined)
-                    .icon_position(IconPosition::Start)
-                    .icon(IconName::Plus)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .start_icon(
+                        Icon::new(IconName::Plus)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small),
             )
             .menu({
@@ -1019,6 +1032,7 @@ impl AgentConfiguration {
             });
 
         v_flex()
+            .min_w_0()
             .border_b_1()
             .border_color(cx.theme().colors().border)
             .child(
@@ -1217,6 +1231,7 @@ impl Render for AgentConfiguration {
                             .id("assistant-configuration-content")
                             .track_scroll(&self.scroll_handle)
                             .size_full()
+                            .min_w_0()
                             .overflow_y_scroll()
                             .child(self.render_agent_servers_section(cx))
                             .child(self.render_context_servers_section(window, cx))

crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs 🔗

@@ -340,10 +340,11 @@ impl AddLlmProviderModal {
                     .child(Label::new("Models").size(LabelSize::Small))
                     .child(
                         Button::new("add-model", "Add Model")
-                            .icon(IconName::Plus)
-                            .icon_position(IconPosition::Start)
-                            .icon_size(IconSize::XSmall)
-                            .icon_color(Color::Muted)
+                            .start_icon(
+                                Icon::new(IconName::Plus)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted),
+                            )
                             .label_size(LabelSize::Small)
                             .on_click(cx.listener(|this, _, window, cx| {
                                 this.input.add_model(window, cx);
@@ -446,10 +447,11 @@ impl AddLlmProviderModal {
             .when(has_more_than_one_model, |this| {
                 this.child(
                     Button::new(("remove-model", ix), "Remove Model")
-                        .icon(IconName::Trash)
-                        .icon_position(IconPosition::Start)
-                        .icon_size(IconSize::XSmall)
-                        .icon_color(Color::Muted)
+                        .start_icon(
+                            Icon::new(IconName::Trash)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                         .label_size(LabelSize::Small)
                         .style(ButtonStyle::Outlined)
                         .full_width()

crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs 🔗

@@ -693,9 +693,11 @@ impl ConfigureContextServerModal {
                 {
                     Some(
                         Button::new("open-repository", "Open Repository")
-                            .icon(IconName::ArrowUpRight)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::Small)
+                            .end_icon(
+                                Icon::new(IconName::ArrowUpRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .tooltip({
                                 let repository_url = repository_url.clone();
                                 move |_window, cx| {

crates/agent_ui/src/agent_connection_store.rs 🔗

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

crates/agent_ui/src/agent_diff.rs 🔗

@@ -686,10 +686,11 @@ impl Render for AgentDiffPane {
                         .child(
                             Button::new("continue-iterating", "Continue Iterating")
                                 .style(ButtonStyle::Filled)
-                                .icon(IconName::ForwardArrow)
-                                .icon_position(IconPosition::Start)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
+                                .start_icon(
+                                    Icon::new(IconName::ForwardArrow)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .full_width()
                                 .key_binding(KeyBinding::for_action_in(
                                     &ToggleFocus,
@@ -831,6 +832,7 @@ fn render_diff_hunk_controls(
                                         &snapshot,
                                         position,
                                         Direction::Next,
+                                        true,
                                         window,
                                         cx,
                                     );
@@ -866,6 +868,7 @@ fn render_diff_hunk_controls(
                                         &snapshot,
                                         point,
                                         Direction::Prev,
+                                        true,
                                         window,
                                         cx,
                                     );

crates/agent_ui/src/agent_model_selector.rs 🔗

@@ -9,7 +9,7 @@ use language_model::IconOrSvg;
 use picker::popover_menu::PickerPopoverMenu;
 use settings::update_settings_file;
 use std::sync::Arc;
-use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
+use ui::{PopoverMenuHandle, Tooltip, prelude::*};
 
 pub struct AgentModelSelector {
     selector: Entity<LanguageModelSelector>,
@@ -112,9 +112,11 @@ impl Render for AgentModelSelector {
 
         PickerPopoverMenu::new(
             self.selector.clone(),
-            ButtonLike::new("active-model")
+            Button::new("active-model", model_name)
+                .label_size(LabelSize::Small)
+                .color(color)
                 .when_some(provider_icon, |this, icon| {
-                    this.child(
+                    this.start_icon(
                         match icon {
                             IconOrSvg::Svg(path) => Icon::from_external_svg(path),
                             IconOrSvg::Icon(name) => Icon::new(name),
@@ -123,14 +125,7 @@ impl Render for AgentModelSelector {
                         .size(IconSize::XSmall),
                     )
                 })
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                .child(
-                    Label::new(model_name)
-                        .color(color)
-                        .size(LabelSize::Small)
-                        .ml_0p5(),
-                )
-                .child(
+                .end_icon(
                     Icon::new(IconName::ChevronDown)
                         .color(color)
                         .size(IconSize::XSmall),

crates/agent_ui/src/agent_panel.rs 🔗

@@ -9,10 +9,11 @@ use std::{
     time::Duration,
 };
 
-use acp_thread::{AcpThread, AgentSessionInfo, MentionUri, ThreadStatus};
+use acp_thread::{AcpThread, MentionUri, ThreadStatus};
 use agent::{ContextServerRegistry, SharedThread, ThreadStore};
 use agent_client_protocol as acp;
 use agent_servers::AgentServer;
+use collections::HashSet;
 use db::kvp::{Dismissable, KEY_VALUE_STORE};
 use itertools::Itertools;
 use project::{
@@ -23,15 +24,17 @@ use serde::{Deserialize, Serialize};
 use settings::{LanguageModelProviderSetting, LanguageModelSelection};
 
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
-use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff};
+use zed_actions::agent::{
+    ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent,
+    ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff,
+};
 
-use crate::ManageProfiles;
-use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
+use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault};
 use crate::{
-    AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow,
-    InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown,
-    OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
-    ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
+    AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, CycleStartThreadIn,
+    Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread,
+    OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
+    StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
     agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
     connection_view::{AcpThreadViewEvent, ThreadView},
     slash_command::SlashCommandCompletionProvider,
@@ -39,15 +42,18 @@ use crate::{
     ui::EndTrialUpsell,
 };
 use crate::{
-    AgentInitialContent, ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary,
+    Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
+    NewNativeAgentThreadFromSummary,
 };
 use crate::{
-    ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
+    ExpandMessageEditor, ThreadHistoryView,
     text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
 };
+use crate::{ManageProfiles, ThreadHistoryViewEvent};
+use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
 use agent_settings::AgentSettings;
 use ai_onboarding::AgentPanelOnboarding;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_slash_command::SlashCommandWorkingSet;
 use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
 use client::UserStore;
@@ -59,9 +65,10 @@ use extension_host::ExtensionStore;
 use fs::Fs;
 use git::repository::validate_worktree_directory;
 use gpui::{
-    Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
-    DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
-    Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
+    Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem,
+    Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle,
+    Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity,
+    deferred, prelude::*, pulsating_between,
 };
 use language::LanguageRegistry;
 use language_model::{ConfigurationError, LanguageModelRegistry};
@@ -73,14 +80,16 @@ use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, update_settings_file};
 use theme::ThemeSettings;
 use ui::{
-    Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
-    PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
+    Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator, KeyBinding,
+    PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
 };
-use util::ResultExt as _;
+use util::{ResultExt as _, debug_panic};
 use workspace::{
-    CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
-    WorkspaceId,
+    CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar,
+    MultiWorkspace, OpenResult, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom,
+    ToolbarItemView, Workspace, WorkspaceId,
     dock::{DockPosition, Panel, PanelEvent},
+    multi_workspace_enabled,
 };
 use zed_actions::{
     DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
@@ -92,6 +101,55 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
 const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
 const DEFAULT_THREAD_TITLE: &str = "New Thread";
 
+#[derive(Default)]
+struct SidebarsByWindow(
+    collections::HashMap<gpui::WindowId, gpui::WeakEntity<crate::sidebar::Sidebar>>,
+);
+
+impl gpui::Global for SidebarsByWindow {}
+
+pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool {
+    if !multi_workspace_enabled(cx) {
+        return false;
+    }
+    let window_id = window.window_handle().window_id();
+    cx.try_global::<SidebarsByWindow>()
+        .and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade())
+        .is_some_and(|sidebar| sidebar.read(cx).is_open())
+}
+
+fn find_or_create_sidebar_for_window(
+    window: &mut Window,
+    cx: &mut App,
+) -> Option<Entity<crate::sidebar::Sidebar>> {
+    let window_id = window.window_handle().window_id();
+    let multi_workspace = window.root::<MultiWorkspace>().flatten()?;
+
+    if !cx.has_global::<SidebarsByWindow>() {
+        cx.set_global(SidebarsByWindow::default());
+    }
+
+    cx.global_mut::<SidebarsByWindow>()
+        .0
+        .retain(|_, weak| weak.upgrade().is_some());
+
+    let existing = cx
+        .global::<SidebarsByWindow>()
+        .0
+        .get(&window_id)
+        .and_then(|weak| weak.upgrade());
+
+    if let Some(sidebar) = existing {
+        return Some(sidebar);
+    }
+
+    let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx));
+    cx.global_mut::<SidebarsByWindow>()
+        .0
+        .insert(window_id, sidebar.downgrade());
+    Some(sidebar)
+}
+
 fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
     let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
     let key = i64::from(workspace_id).to_string();
@@ -190,7 +248,16 @@ pub fn init(cx: &mut App) {
                     if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
                         workspace.focus_panel::<AgentPanel>(window, cx);
                         panel.update(cx, |panel, cx| {
-                            panel.external_thread(action.agent.clone(), None, None, window, cx)
+                            panel.external_thread(
+                                action.agent.clone(),
+                                None,
+                                None,
+                                None,
+                                None,
+                                true,
+                                window,
+                                cx,
+                            )
                         });
                     }
                 })
@@ -208,7 +275,7 @@ pub fn init(cx: &mut App) {
                 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
                     let thread = workspace
                         .panel::<AgentPanel>(cx)
-                        .and_then(|panel| panel.read(cx).active_thread_view().cloned())
+                        .and_then(|panel| panel.read(cx).active_connection_view().cloned())
                         .and_then(|thread_view| {
                             thread_view
                                 .read(cx)
@@ -321,32 +388,236 @@ pub fn init(cx: &mut App) {
 
                     panel.update(cx, |panel, cx| {
                         panel.external_thread(
+                            None,
+                            None,
                             None,
                             None,
                             Some(AgentInitialContent::ContentBlock {
                                 blocks: content_blocks,
                                 auto_submit: true,
                             }),
+                            true,
                             window,
                             cx,
                         );
                     });
                 })
+                .register_action(
+                    |workspace, action: &ResolveConflictsWithAgent, window, cx| {
+                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
+                            return;
+                        };
+
+                        let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
+
+                        workspace.focus_panel::<AgentPanel>(window, cx);
+
+                        panel.update(cx, |panel, cx| {
+                            panel.external_thread(
+                                None,
+                                None,
+                                None,
+                                None,
+                                Some(AgentInitialContent::ContentBlock {
+                                    blocks: content_blocks,
+                                    auto_submit: true,
+                                }),
+                                true,
+                                window,
+                                cx,
+                            );
+                        });
+                    },
+                )
+                .register_action(
+                    |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
+                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
+                            return;
+                        };
+
+                        let content_blocks =
+                            build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
+
+                        workspace.focus_panel::<AgentPanel>(window, cx);
+
+                        panel.update(cx, |panel, cx| {
+                            panel.external_thread(
+                                None,
+                                None,
+                                None,
+                                None,
+                                Some(AgentInitialContent::ContentBlock {
+                                    blocks: content_blocks,
+                                    auto_submit: true,
+                                }),
+                                true,
+                                window,
+                                cx,
+                            );
+                        });
+                    },
+                )
                 .register_action(|workspace, action: &StartThreadIn, _window, cx| {
                     if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
                         panel.update(cx, |panel, cx| {
                             panel.set_start_thread_in(action, cx);
                         });
                     }
+                })
+                .register_action(|workspace, _: &CycleStartThreadIn, _window, cx| {
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        panel.update(cx, |panel, cx| {
+                            panel.cycle_start_thread_in(cx);
+                        });
+                    }
+                })
+                .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| {
+                    if !multi_workspace_enabled(cx) {
+                        return;
+                    }
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        if let Some(sidebar) = panel.read(cx).sidebar.clone() {
+                            let was_open = sidebar.read(cx).is_open();
+                            sidebar.update(cx, |sidebar, cx| {
+                                sidebar.toggle(window, cx);
+                            });
+                            // When closing the sidebar, restore focus to the active pane
+                            // to avoid "zombie focus" on the now-hidden sidebar elements
+                            if was_open {
+                                let active_pane = workspace.active_pane().clone();
+                                let pane_focus = active_pane.read(cx).focus_handle(cx);
+                                window.focus(&pane_focus, cx);
+                            }
+                        }
+                    }
+                })
+                .register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| {
+                    if !multi_workspace_enabled(cx) {
+                        return;
+                    }
+                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                        if let Some(sidebar) = panel.read(cx).sidebar.clone() {
+                            sidebar.update(cx, |sidebar, cx| {
+                                sidebar.focus_or_unfocus(workspace, window, cx);
+                            });
+                        }
+                    }
                 });
         },
     )
     .detach();
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-enum HistoryKind {
-    AgentThreads,
+fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
+    let mention_uri = MentionUri::MergeConflict {
+        file_path: conflict.file_path.clone(),
+    };
+    acp::ContentBlock::Resource(acp::EmbeddedResource::new(
+        acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
+            conflict.conflict_text.clone(),
+            mention_uri.to_uri().to_string(),
+        )),
+    ))
+}
+
+fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
+    if conflicts.is_empty() {
+        return Vec::new();
+    }
+
+    let mut blocks = Vec::new();
+
+    if conflicts.len() == 1 {
+        let conflict = &conflicts[0];
+
+        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
+            "Please resolve the following merge conflict in ",
+        )));
+        let mention = MentionUri::File {
+            abs_path: PathBuf::from(conflict.file_path.clone()),
+        };
+        blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+            mention.name(),
+            mention.to_uri(),
+        )));
+
+        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
+            indoc::formatdoc!(
+                "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
+
+                Analyze both versions carefully and resolve the conflict by editing \
+                the file directly. Choose the resolution that best preserves the intent \
+                of both changes, or combine them if appropriate.
+
+                ",
+                ours = conflict.ours_branch_name,
+                theirs = conflict.theirs_branch_name,
+            ),
+        )));
+    } else {
+        let n = conflicts.len();
+        let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
+        let ours = &conflicts[0].ours_branch_name;
+        let theirs = &conflicts[0].theirs_branch_name;
+        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
+            indoc::formatdoc!(
+                "Please resolve all {n} merge conflicts below.
+
+                The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
+
+                For each conflict, analyze both versions carefully and resolve them \
+                by editing the file{suffix} directly. Choose resolutions that best preserve \
+                the intent of both changes, or combine them if appropriate.
+
+                ",
+                suffix = if unique_files.len() > 1 { "s" } else { "" },
+            ),
+        )));
+    }
+
+    for conflict in conflicts {
+        blocks.push(conflict_resource_block(conflict));
+    }
+
+    blocks
+}
+
+fn build_conflicted_files_resolution_prompt(
+    conflicted_file_paths: &[String],
+) -> Vec<acp::ContentBlock> {
+    if conflicted_file_paths.is_empty() {
+        return Vec::new();
+    }
+
+    let instruction = indoc::indoc!(
+        "The following files have unresolved merge conflicts. Please open each \
+         file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
+         and resolve every conflict by editing the files directly.
+
+         Choose resolutions that best preserve the intent of both changes, \
+         or combine them if appropriate.
+
+         Files with conflicts:
+         ",
+    );
+
+    let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
+    for path in conflicted_file_paths {
+        let mention = MentionUri::File {
+            abs_path: PathBuf::from(path),
+        };
+        content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+            mention.name(),
+            mention.to_uri(),
+        )));
+        content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
+    }
+    content
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum History {
+    AgentThreads { view: Entity<ThreadHistoryView> },
     TextThreads,
 }
 
@@ -362,7 +633,7 @@ enum ActiveView {
         _subscriptions: Vec<gpui::Subscription>,
     },
     History {
-        kind: HistoryKind,
+        history: History,
     },
     Configuration,
 }
@@ -374,7 +645,7 @@ enum WhichFontSize {
 }
 
 // TODO unify this with ExternalAgent
-#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, PartialEq, Serialize)]
 pub enum AgentType {
     #[default]
     NativeAgent,
@@ -384,6 +655,63 @@ pub enum AgentType {
     },
 }
 
+// Custom impl handles legacy variant names from before the built-in agents were moved to
+// the registry: "ClaudeAgent" -> Custom { name: "claude-acp" }, "Codex" -> Custom { name:
+// "codex-acp" }, "Gemini" -> Custom { name: "gemini" }.
+// Can be removed at some point in the future and go back to #[derive(Deserialize)].
+impl<'de> Deserialize<'de> for AgentType {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let value = serde_json::Value::deserialize(deserializer)?;
+
+        if let Some(s) = value.as_str() {
+            return match s {
+                "NativeAgent" => Ok(Self::NativeAgent),
+                "TextThread" => Ok(Self::TextThread),
+                "ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom {
+                    name: CLAUDE_AGENT_NAME.into(),
+                }),
+                "Codex" => Ok(Self::Custom {
+                    name: CODEX_NAME.into(),
+                }),
+                "Gemini" => Ok(Self::Custom {
+                    name: GEMINI_NAME.into(),
+                }),
+                other => Err(serde::de::Error::unknown_variant(
+                    other,
+                    &[
+                        "NativeAgent",
+                        "TextThread",
+                        "Custom",
+                        "ClaudeAgent",
+                        "ClaudeCode",
+                        "Codex",
+                        "Gemini",
+                    ],
+                )),
+            };
+        }
+
+        if let Some(obj) = value.as_object() {
+            if let Some(inner) = obj.get("Custom") {
+                #[derive(Deserialize)]
+                struct CustomFields {
+                    name: SharedString,
+                }
+                let fields: CustomFields =
+                    serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?;
+                return Ok(Self::Custom { name: fields.name });
+            }
+        }
+
+        Err(serde::de::Error::custom(
+            "expected a string variant or {\"Custom\": {\"name\": ...}}",
+        ))
+    }
+}
+
 impl AgentType {
     pub fn is_native(&self) -> bool {
         matches!(self, Self::NativeAgent)
@@ -404,11 +732,11 @@ impl AgentType {
     }
 }
 
-impl From<ExternalAgent> for AgentType {
-    fn from(value: ExternalAgent) -> Self {
+impl From<Agent> for AgentType {
+    fn from(value: Agent) -> Self {
         match value {
-            ExternalAgent::Custom { name } => Self::Custom { name },
-            ExternalAgent::NativeAgent => Self::NativeAgent,
+            Agent::Custom { name } => Self::Custom { name },
+            Agent::NativeAgent => Self::NativeAgent,
         }
     }
 }
@@ -416,17 +744,10 @@ impl From<ExternalAgent> for AgentType {
 impl StartThreadIn {
     fn label(&self) -> SharedString {
         match self {
-            Self::LocalProject => "Local Project".into(),
+            Self::LocalProject => "Current Project".into(),
             Self::NewWorktree => "New Worktree".into(),
         }
     }
-
-    fn icon(&self) -> IconName {
-        match self {
-            Self::LocalProject => IconName::Screen,
-            Self::NewWorktree => IconName::GitBranchPlus,
-        }
-    }
 }
 
 #[derive(Clone, Debug)]
@@ -543,11 +864,11 @@ pub struct AgentPanel {
     project: Entity<Project>,
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
-    acp_history: Entity<ThreadHistory>,
     text_thread_history: Entity<TextThreadHistory>,
     thread_store: Entity<ThreadStore>,
     text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
     prompt_store: Option<Entity<PromptStore>>,
+    connection_store: Entity<AgentConnectionStore>,
     context_server_registry: Entity<ContextServerRegistry>,
     configuration: Option<Entity<AgentConfiguration>>,
     configuration_subscription: Option<Subscription>,
@@ -566,15 +887,17 @@ pub struct AgentPanel {
     zoomed: bool,
     pending_serialization: Option<Task<Result<()>>>,
     onboarding: Entity<AgentPanelOnboarding>,
-    selected_agent: AgentType,
+    selected_agent_type: AgentType,
     start_thread_in: StartThreadIn,
     worktree_creation_status: Option<WorktreeCreationStatus>,
     _thread_view_subscription: Option<Subscription>,
+    _active_thread_focus_subscription: Option<Subscription>,
     _worktree_creation_task: Option<Task<()>>,
     show_trust_workspace_message: bool,
     last_configuration_error_telemetry: Option<String>,
     on_boarding_upsell_dismissed: AtomicBool,
     _active_view_observation: Option<Subscription>,
+    pub(crate) sidebar: Option<Entity<crate::sidebar::Sidebar>>,
 }
 
 impl AgentPanel {
@@ -584,7 +907,7 @@ impl AgentPanel {
         };
 
         let width = self.width;
-        let selected_agent = self.selected_agent.clone();
+        let selected_agent_type = self.selected_agent_type.clone();
         let start_thread_in = Some(self.start_thread_in);
 
         let last_active_thread = self.active_agent_thread(cx).map(|thread| {
@@ -592,7 +915,7 @@ impl AgentPanel {
             let title = thread.title();
             SerializedActiveThread {
                 session_id: thread.session_id().0.to_string(),
-                agent_type: self.selected_agent.clone(),
+                agent_type: self.selected_agent_type.clone(),
                 title: if title.as_ref() != DEFAULT_THREAD_TITLE {
                     Some(title.to_string())
                 } else {
@@ -607,7 +930,7 @@ impl AgentPanel {
                 workspace_id,
                 SerializedAgentPanel {
                     width,
-                    selected_agent: Some(selected_agent),
+                    selected_agent: Some(selected_agent_type),
                     last_active_thread,
                     start_thread_in,
                 },
@@ -693,7 +1016,7 @@ impl AgentPanel {
                     panel.update(cx, |panel, cx| {
                         panel.width = serialized_panel.width.map(|w| w.round());
                         if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
-                            panel.selected_agent = selected_agent;
+                            panel.selected_agent_type = selected_agent;
                         }
                         if let Some(start_thread_in) = serialized_panel.start_thread_in {
                             let is_worktree_flag_enabled =
@@ -720,16 +1043,19 @@ impl AgentPanel {
 
                 if let Some(thread_info) = last_active_thread {
                     let agent_type = thread_info.agent_type.clone();
-                    let session_info = AgentSessionInfo {
-                        session_id: acp::SessionId::new(thread_info.session_id),
-                        cwd: thread_info.cwd,
-                        title: thread_info.title.map(SharedString::from),
-                        updated_at: None,
-                        meta: None,
-                    };
                     panel.update(cx, |panel, cx| {
-                        panel.selected_agent = agent_type;
-                        panel.load_agent_thread(session_info, window, cx);
+                        panel.selected_agent_type = agent_type;
+                        if let Some(agent) = panel.selected_agent() {
+                            panel.load_agent_thread(
+                                agent,
+                                thread_info.session_id.into(),
+                                thread_info.cwd,
+                                thread_info.title.map(SharedString::from),
+                                false,
+                                window,
+                                cx,
+                            );
+                        }
                     });
                 }
                 panel
@@ -753,24 +1079,13 @@ impl AgentPanel {
         let client = workspace.client().clone();
         let workspace_id = workspace.database_id();
         let workspace = workspace.weak_handle();
-
         let context_server_registry =
             cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 
         let thread_store = ThreadStore::global(cx);
-        let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx));
         let text_thread_history =
             cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
-        cx.subscribe_in(
-            &acp_history,
-            window,
-            |this, _, event, window, cx| match event {
-                ThreadHistoryEvent::Open(thread) => {
-                    this.load_agent_thread(thread.clone(), window, cx);
-                }
-            },
-        )
-        .detach();
+
         cx.subscribe_in(
             &text_thread_history,
             window,
@@ -790,15 +1105,18 @@ impl AgentPanel {
         window.defer(cx, move |window, cx| {
             let panel = weak_panel.clone();
             let agent_navigation_menu =
-                ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
+                ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
                     if let Some(panel) = panel.upgrade() {
-                        if let Some(kind) = panel.read(cx).history_kind_for_selected_agent(cx) {
-                            menu =
-                                Self::populate_recently_updated_menu_section(menu, panel, kind, cx);
-                            let view_all_label = match kind {
-                                HistoryKind::AgentThreads => "View All",
-                                HistoryKind::TextThreads => "View All Text Threads",
+                        if let Some(history) = panel
+                            .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx))
+                        {
+                            let view_all_label = match history {
+                                History::AgentThreads { .. } => "View All",
+                                History::TextThreads => "View All Text Threads",
                             };
+                            menu = Self::populate_recently_updated_menu_section(
+                                menu, panel, history, cx,
+                            );
                             menu = menu.action(view_all_label, Box::new(OpenHistory));
                         }
                     }
@@ -864,6 +1182,17 @@ impl AgentPanel {
             None
         };
 
+        let connection_store = cx.new(|cx| {
+            let mut store = AgentConnectionStore::new(project.clone(), cx);
+            // Register the native agent right away, so that it is available for
+            // the inline assistant etc.
+            store.request_connection(
+                Agent::NativeAgent,
+                Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
+                cx,
+            );
+            store
+        });
         let mut panel = Self {
             workspace_id,
             active_view,
@@ -874,6 +1203,7 @@ impl AgentPanel {
             language_registry,
             text_thread_store,
             prompt_store,
+            connection_store,
             configuration: None,
             configuration_subscription: None,
             focus_handle: cx.focus_handle(),
@@ -891,22 +1221,29 @@ impl AgentPanel {
             zoomed: false,
             pending_serialization: None,
             onboarding,
-            acp_history,
             text_thread_history,
             thread_store,
-            selected_agent: AgentType::default(),
+            selected_agent_type: AgentType::default(),
             start_thread_in: StartThreadIn::default(),
             worktree_creation_status: None,
             _thread_view_subscription: None,
+            _active_thread_focus_subscription: None,
             _worktree_creation_task: None,
             show_trust_workspace_message: false,
             last_configuration_error_telemetry: None,
             on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
             _active_view_observation: None,
+            sidebar: None,
         };
 
         // Initial sync of agent servers from extensions
         panel.sync_agent_servers_from_extensions(cx);
+
+        cx.defer_in(window, move |this, window, cx| {
+            this.sidebar = find_or_create_sidebar_for_window(window, cx);
+            cx.notify();
+        });
+
         panel
     }
 
@@ -948,20 +1285,25 @@ impl AgentPanel {
         &self.thread_store
     }
 
-    pub fn history(&self) -> &Entity<ThreadHistory> {
-        &self.acp_history
+    pub fn connection_store(&self) -> &Entity<AgentConnectionStore> {
+        &self.connection_store
     }
 
     pub fn open_thread(
         &mut self,
-        thread: AgentSessionInfo,
+        session_id: acp::SessionId,
+        cwd: Option<PathBuf>,
+        title: Option<SharedString>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         self.external_thread(
-            Some(crate::ExternalAgent::NativeAgent),
-            Some(thread),
+            Some(crate::Agent::NativeAgent),
+            Some(session_id),
+            cwd,
+            title,
             None,
+            true,
             window,
             cx,
         );
@@ -988,7 +1330,7 @@ impl AgentPanel {
             .unwrap_or(false)
     }
 
-    pub(crate) fn active_thread_view(&self) -> Option<&Entity<ConnectionView>> {
+    pub fn active_connection_view(&self) -> Option<&Entity<ConnectionView>> {
         match &self.active_view {
             ActiveView::AgentThread { server_view, .. } => Some(server_view),
             ActiveView::Uninitialized
@@ -1008,21 +1350,42 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(thread) = self
-            .acp_history
+        let session_id = action.from_session_id.clone();
+
+        let Some(history) = self
+            .connection_store
             .read(cx)
-            .session_for_id(&action.from_session_id)
+            .entry(&Agent::NativeAgent)
+            .and_then(|e| e.read(cx).history().cloned())
         else {
+            debug_panic!("Native agent is not registered");
             return;
         };
 
-        self.external_thread(
-            Some(ExternalAgent::NativeAgent),
-            None,
-            Some(AgentInitialContent::ThreadSummary(thread)),
-            window,
-            cx,
-        );
+        cx.spawn_in(window, async move |this, cx| {
+            this.update_in(cx, |this, window, cx| {
+                let thread = history
+                    .read(cx)
+                    .session_for_id(&session_id)
+                    .context("Session not found")?;
+
+                this.external_thread(
+                    Some(Agent::NativeAgent),
+                    None,
+                    None,
+                    None,
+                    Some(AgentInitialContent::ThreadSummary {
+                        session_id: thread.session_id,
+                        title: thread.title,
+                    }),
+                    true,
+                    window,
+                    cx,
+                );
+                anyhow::Ok(())
+            })
+        })
+        .detach_and_log_err(cx);
     }
 
     fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -1049,8 +1412,8 @@ impl AgentPanel {
             editor
         });
 
-        if self.selected_agent != AgentType::TextThread {
-            self.selected_agent = AgentType::TextThread;
+        if self.selected_agent_type != AgentType::TextThread {
+            self.selected_agent_type = AgentType::TextThread;
             self.serialize(cx);
         }
 
@@ -1070,9 +1433,12 @@ impl AgentPanel {
 
     fn external_thread(
         &mut self,
-        agent_choice: Option<crate::ExternalAgent>,
-        resume_thread: Option<AgentSessionInfo>,
+        agent_choice: Option<crate::Agent>,
+        resume_session_id: Option<acp::SessionId>,
+        cwd: Option<PathBuf>,
+        title: Option<SharedString>,
         initial_content: Option<AgentInitialContent>,
+        focus: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -1085,67 +1451,80 @@ impl AgentPanel {
 
         #[derive(Serialize, Deserialize)]
         struct LastUsedExternalAgent {
-            agent: crate::ExternalAgent,
+            agent: crate::Agent,
         }
 
         let thread_store = self.thread_store.clone();
 
-        cx.spawn_in(window, async move |this, cx| {
-            let ext_agent = match agent_choice {
-                Some(agent) => {
-                    cx.background_spawn({
-                        let agent = agent.clone();
-                        async move {
-                            if let Some(serialized) =
-                                serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
-                            {
-                                KEY_VALUE_STORE
-                                    .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
-                                    .await
-                                    .log_err();
-                            }
-                        }
-                    })
-                    .detach();
-
-                    agent
-                }
-                None => {
-                    if is_via_collab {
-                        ExternalAgent::NativeAgent
-                    } else {
-                        cx.background_spawn(async move {
-                            KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
-                        })
-                        .await
-                        .log_err()
-                        .flatten()
-                        .and_then(|value| {
-                            serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
-                        })
-                        .map(|agent| agent.agent)
-                        .unwrap_or(ExternalAgent::NativeAgent)
+        if let Some(agent) = agent_choice {
+            cx.background_spawn({
+                let agent = agent.clone();
+                async move {
+                    if let Some(serialized) =
+                        serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
+                    {
+                        KEY_VALUE_STORE
+                            .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
+                            .await
+                            .log_err();
                     }
                 }
-            };
+            })
+            .detach();
 
-            let server = ext_agent.server(fs, thread_store);
-            this.update_in(cx, |agent_panel, window, cx| {
-                agent_panel.create_external_thread(
-                    server,
-                    resume_thread,
-                    initial_content,
-                    workspace,
-                    project,
-                    ext_agent,
-                    window,
-                    cx,
-                );
-            })?;
+            let server = agent.server(fs, thread_store);
+            self.create_agent_thread(
+                server,
+                resume_session_id,
+                cwd,
+                title,
+                initial_content,
+                workspace,
+                project,
+                agent,
+                focus,
+                window,
+                cx,
+            );
+        } else {
+            cx.spawn_in(window, async move |this, cx| {
+                let ext_agent = if is_via_collab {
+                    Agent::NativeAgent
+                } else {
+                    cx.background_spawn(async move {
+                        KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
+                    })
+                    .await
+                    .log_err()
+                    .flatten()
+                    .and_then(|value| {
+                        serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
+                    })
+                    .map(|agent| agent.agent)
+                    .unwrap_or(Agent::NativeAgent)
+                };
 
-            anyhow::Ok(())
-        })
-        .detach_and_log_err(cx);
+                let server = ext_agent.server(fs, thread_store);
+                this.update_in(cx, |agent_panel, window, cx| {
+                    agent_panel.create_agent_thread(
+                        server,
+                        resume_session_id,
+                        cwd,
+                        title,
+                        initial_content,
+                        workspace,
+                        project,
+                        ext_agent,
+                        focus,
+                        window,
+                        cx,
+                    );
+                })?;
+
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+        }
     }
 
     fn deploy_rules_library(
@@ -1173,7 +1552,7 @@ impl AgentPanel {
     }
 
     fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(thread_view) = self.active_thread_view() else {
+        let Some(thread_view) = self.active_connection_view() else {
             return;
         };
 

crates/agent_ui/src/agent_registry_ui.rs 🔗

@@ -467,10 +467,11 @@ impl AgentRegistryPage {
                 let agent_id = agent.id().to_string();
                 Button::new(button_id, "Install")
                     .style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                    .icon(IconName::Download)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
-                    .icon_position(IconPosition::Start)
+                    .start_icon(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .on_click(move |_, _, cx| {
                         let agent_id = agent_id.clone();
                         update_settings_file(fs.clone(), cx, move |settings, _| {
@@ -541,9 +542,11 @@ impl Render for AgentRegistryPage {
                                 Button::new("learn-more", "Learn More")
                                     .style(ButtonStyle::Outlined)
                                     .size(ButtonSize::Medium)
-                                    .icon(IconName::ArrowUpRight)
-                                    .icon_color(Color::Muted)
-                                    .icon_size(IconSize::Small)
+                                    .end_icon(
+                                        Icon::new(IconName::ArrowUpRight)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
                                     .on_click(move |_, _, cx| {
                                         cx.open_url(&zed_urls::acp_registry_blog(cx))
                                     }),

crates/agent_ui/src/agent_ui.rs 🔗

@@ -1,4 +1,5 @@
 mod agent_configuration;
+pub(crate) mod agent_connection_store;
 mod agent_diff;
 mod agent_model_selector;
 mod agent_panel;
@@ -11,6 +12,7 @@ pub(crate) mod connection_view;
 mod context;
 mod context_server_configuration;
 mod entry_view_state;
+mod external_source_prompt;
 mod favorite_models;
 mod inline_assistant;
 mod inline_prompt_editor;
@@ -21,6 +23,7 @@ mod mode_selector;
 mod model_selector;
 mod model_selector_popover;
 mod profile_selector;
+pub mod sidebar;
 mod slash_command;
 mod slash_command_picker;
 mod terminal_codegen;
@@ -30,11 +33,14 @@ pub mod test_support;
 mod text_thread_editor;
 mod text_thread_history;
 mod thread_history;
+mod thread_history_view;
+mod threads_archive_view;
 mod ui;
 
 use std::rc::Rc;
 use std::sync::Arc;
 
+use agent_client_protocol as acp;
 use agent_settings::{AgentProfileId, AgentSettings};
 use assistant_slash_command::SlashCommandRegistry;
 use client::Client;
@@ -65,11 +71,13 @@ use crate::agent_registry_ui::AgentRegistryPage;
 pub use crate::inline_assistant::InlineAssistant;
 pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
 pub(crate) use connection_view::ConnectionView;
+pub use external_source_prompt::ExternalSourcePrompt;
 pub(crate) use mode_selector::ModeSelector;
 pub(crate) use model_selector::ModelSelector;
 pub(crate) use model_selector_popover::ModelSelectorPopover;
 pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
-pub(crate) use thread_history::*;
+pub(crate) use thread_history::ThreadHistory;
+pub(crate) use thread_history_view::*;
 use zed_actions;
 
 actions!(
@@ -79,6 +87,8 @@ actions!(
         NewTextThread,
         /// Toggles the menu to create new agent threads.
         ToggleNewThreadMenu,
+        /// Cycles through the options for where new threads start (current project or new worktree).
+        CycleStartThreadIn,
         /// Toggles the navigation menu for switching between threads and views.
         ToggleNavigationMenu,
         /// Toggles the options menu for agent settings and preferences.
@@ -196,7 +206,7 @@ pub struct NewThread;
 #[serde(deny_unknown_fields)]
 pub struct NewExternalAgentThread {
     /// Which agent to use for the conversation.
-    agent: Option<ExternalAgent>,
+    agent: Option<Agent>,
 }
 
 #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
@@ -207,14 +217,71 @@ pub struct NewNativeAgentThreadFromSummary {
 }
 
 // TODO unify this with AgentType
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
-pub enum ExternalAgent {
+pub enum Agent {
     NativeAgent,
     Custom { name: SharedString },
 }
 
-impl ExternalAgent {
+// Custom impl handles legacy variant names from before the built-in agents were moved to
+// the registry: "claude_code" -> Custom { name: "claude-acp" }, "codex" -> Custom { name:
+// "codex-acp" }, "gemini" -> Custom { name: "gemini" }.
+// Can be removed at some point in the future and go back to #[derive(Deserialize)].
+impl<'de> serde::Deserialize<'de> for Agent {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME};
+
+        let value = serde_json::Value::deserialize(deserializer)?;
+
+        if let Some(s) = value.as_str() {
+            return match s {
+                "native_agent" => Ok(Self::NativeAgent),
+                "claude_code" | "claude_agent" => Ok(Self::Custom {
+                    name: CLAUDE_AGENT_NAME.into(),
+                }),
+                "codex" => Ok(Self::Custom {
+                    name: CODEX_NAME.into(),
+                }),
+                "gemini" => Ok(Self::Custom {
+                    name: GEMINI_NAME.into(),
+                }),
+                other => Err(serde::de::Error::unknown_variant(
+                    other,
+                    &[
+                        "native_agent",
+                        "custom",
+                        "claude_agent",
+                        "claude_code",
+                        "codex",
+                        "gemini",
+                    ],
+                )),
+            };
+        }
+
+        if let Some(obj) = value.as_object() {
+            if let Some(inner) = obj.get("custom") {
+                #[derive(serde::Deserialize)]
+                struct CustomFields {
+                    name: SharedString,
+                }
+                let fields: CustomFields =
+                    serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?;
+                return Ok(Self::Custom { name: fields.name });
+            }
+        }
+
+        Err(serde::de::Error::custom(
+            "expected a string variant or {\"custom\": {\"name\": ...}}",
+        ))
+    }
+}
+
+impl Agent {
     pub fn server(
         &self,
         fs: Arc<dyn fs::Fs>,
@@ -241,11 +308,21 @@ pub enum StartThreadIn {
 
 /// Content to initialize new external agent with.
 pub enum AgentInitialContent {
-    ThreadSummary(acp_thread::AgentSessionInfo),
+    ThreadSummary {
+        session_id: acp::SessionId,
+        title: Option<SharedString>,
+    },
     ContentBlock {
         blocks: Vec<agent_client_protocol::ContentBlock>,
         auto_submit: bool,
     },
+    FromExternalSource(ExternalSourcePrompt),
+}
+
+impl From<ExternalSourcePrompt> for AgentInitialContent {
+    fn from(prompt: ExternalSourcePrompt) -> Self {
+        Self::FromExternalSource(prompt)
+    }
 }
 
 /// Opens the profile management interface for configuring agent tools and settings.
@@ -579,6 +656,7 @@ mod tests {
             message_editor_min_lines: 1,
             tool_permissions: Default::default(),
             show_turn_stats: false,
+            new_thread_location: Default::default(),
         };
 
         cx.update(|cx| {
@@ -670,4 +748,42 @@ mod tests {
             );
         });
     }
+
+    #[test]
+    fn test_deserialize_legacy_external_agent_variants() {
+        use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME};
+
+        assert_eq!(
+            serde_json::from_str::<Agent>(r#""claude_code""#).unwrap(),
+            Agent::Custom {
+                name: CLAUDE_AGENT_NAME.into(),
+            },
+        );
+        assert_eq!(
+            serde_json::from_str::<Agent>(r#""codex""#).unwrap(),
+            Agent::Custom {
+                name: CODEX_NAME.into(),
+            },
+        );
+        assert_eq!(
+            serde_json::from_str::<Agent>(r#""gemini""#).unwrap(),
+            Agent::Custom {
+                name: GEMINI_NAME.into(),
+            },
+        );
+    }
+
+    #[test]
+    fn test_deserialize_current_external_agent_variants() {
+        assert_eq!(
+            serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
+            Agent::NativeAgent,
+        );
+        assert_eq!(
+            serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
+            Agent::Custom {
+                name: "my-agent".into(),
+            },
+        );
+    }
 }

crates/agent_ui/src/completion_provider.rs 🔗

@@ -5,7 +5,8 @@ use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
 use crate::ThreadHistory;
-use acp_thread::{AgentSessionInfo, MentionUri};
+use acp_thread::MentionUri;
+use agent_client_protocol as acp;
 use anyhow::Result;
 use editor::{
     CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
@@ -63,6 +64,7 @@ pub(crate) enum PromptContextType {
     Thread,
     Rules,
     Diagnostics,
+    BranchDiff,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -101,6 +103,7 @@ impl TryFrom<&str> for PromptContextType {
             "thread" => Ok(Self::Thread),
             "rule" => Ok(Self::Rules),
             "diagnostics" => Ok(Self::Diagnostics),
+            "diff" => Ok(Self::BranchDiff),
             _ => Err(format!("Invalid context picker mode: {}", value)),
         }
     }
@@ -115,6 +118,7 @@ impl PromptContextType {
             Self::Thread => "thread",
             Self::Rules => "rule",
             Self::Diagnostics => "diagnostics",
+            Self::BranchDiff => "branch diff",
         }
     }
 
@@ -126,6 +130,7 @@ impl PromptContextType {
             Self::Thread => "Threads",
             Self::Rules => "Rules",
             Self::Diagnostics => "Diagnostics",
+            Self::BranchDiff => "Branch Diff",
         }
     }
 
@@ -137,6 +142,7 @@ impl PromptContextType {
             Self::Thread => IconName::Thread,
             Self::Rules => IconName::Reader,
             Self::Diagnostics => IconName::Warning,
+            Self::BranchDiff => IconName::GitBranch,
         }
     }
 }
@@ -144,11 +150,17 @@ impl PromptContextType {
 pub(crate) enum Match {
     File(FileMatch),
     Symbol(SymbolMatch),
-    Thread(AgentSessionInfo),
-    RecentThread(AgentSessionInfo),
+    Thread(SessionMatch),
+    RecentThread(SessionMatch),
     Fetch(SharedString),
     Rules(RulesContextEntry),
     Entry(EntryMatch),
+    BranchDiff(BranchDiffMatch),
+}
+
+#[derive(Debug, Clone)]
+pub struct BranchDiffMatch {
+    pub base_ref: SharedString,
 }
 
 impl Match {
@@ -161,19 +173,24 @@ impl Match {
             Match::Symbol(_) => 1.,
             Match::Rules(_) => 1.,
             Match::Fetch(_) => 1.,
+            Match::BranchDiff(_) => 1.,
         }
     }
 }
 
+#[derive(Debug, Clone)]
+pub struct SessionMatch {
+    session_id: acp::SessionId,
+    title: SharedString,
+}
+
 pub struct EntryMatch {
     mat: Option<StringMatch>,
     entry: PromptContextEntry,
 }
 
-fn session_title(session: &AgentSessionInfo) -> SharedString {
-    session
-        .title
-        .clone()
+fn session_title(title: Option<SharedString>) -> SharedString {
+    title
         .filter(|title| !title.is_empty())
         .unwrap_or_else(|| SharedString::new_static("New Thread"))
 }
@@ -266,7 +283,8 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
     }
 
     fn completion_for_thread(
-        thread_entry: AgentSessionInfo,
+        session_id: acp::SessionId,
+        title: Option<SharedString>,
         source_range: Range<Anchor>,
         recent: bool,
         source: Arc<T>,
@@ -275,9 +293,9 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
         workspace: Entity<Workspace>,
         cx: &mut App,
     ) -> Completion {
-        let title = session_title(&thread_entry);
+        let title = session_title(title);
         let uri = MentionUri::Thread {
-            id: thread_entry.session_id,
+            id: session_id,
             name: title.to_string(),
         };
 
@@ -775,6 +793,47 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
         }
     }
 
+    fn build_branch_diff_completion(
+        base_ref: SharedString,
+        source_range: Range<Anchor>,
+        source: Arc<T>,
+        editor: WeakEntity<Editor>,
+        mention_set: WeakEntity<MentionSet>,
+        workspace: Entity<Workspace>,
+        cx: &mut App,
+    ) -> Completion {
+        let uri = MentionUri::GitDiff {
+            base_ref: base_ref.to_string(),
+        };
+        let crease_text: SharedString = format!("Branch Diff (vs {})", base_ref).into();
+        let display_text = format!("@{}", crease_text);
+        let new_text = format!("[{}]({}) ", display_text, uri.to_uri());
+        let new_text_len = new_text.len();
+        let icon_path = uri.icon_path(cx);
+
+        Completion {
+            replace_range: source_range.clone(),
+            new_text,
+            label: CodeLabel::plain(crease_text.to_string(), None),
+            documentation: None,
+            source: project::CompletionSource::Custom,
+            icon_path: Some(icon_path),
+            match_start: None,
+            snippet_deduplication_key: None,
+            insert_text_mode: None,
+            confirm: Some(confirm_completion_callback(
+                crease_text,
+                source_range.start,
+                new_text_len - 1,
+                uri,
+                source,
+                editor,
+                mention_set,
+                workspace,
+            )),
+        }
+    }
+
     fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
         let commands = self.source.available_commands(cx);
         if commands.is_empty() {
@@ -806,6 +865,27 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
         })
     }
 
+    fn fetch_branch_diff_match(
+        &self,
+        workspace: &Entity<Workspace>,
+        cx: &mut App,
+    ) -> Option<Task<Option<BranchDiffMatch>>> {
+        let project = workspace.read(cx).project().clone();
+        let repo = project.read(cx).active_repository(cx)?;
+
+        let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
+
+        Some(cx.spawn(async move |_cx| {
+            let base_ref = default_branch_receiver
+                .await
+                .ok()
+                .and_then(|r| r.ok())
+                .flatten()?;
+
+            Some(BranchDiffMatch { base_ref })
+        }))
+    }
+
     fn search_mentions(
         &self,
         mode: Option<PromptContextType>,
@@ -841,7 +921,15 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
 
             Some(PromptContextType::Thread) => {
                 if let Some(history) = self.history.upgrade() {
-                    let sessions = history.read(cx).sessions().to_vec();
+                    let sessions = history
+                        .read(cx)
+                        .sessions()
+                        .iter()
+                        .map(|session| SessionMatch {
+                            session_id: session.session_id.clone(),
+                            title: session_title(session.title.clone()),
+                        })
+                        .collect::<Vec<_>>();
                     let search_task =
                         filter_sessions_by_query(query, cancellation_flag, sessions, cx);
                     cx.spawn(async move |_cx| {
@@ -878,6 +966,8 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
 
             Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()),
 
+            Some(PromptContextType::BranchDiff) => Task::ready(Vec::new()),
+
             None if query.is_empty() => {
                 let recent_task = self.recent_context_picker_entries(&workspace, cx);
                 let entries = self
@@ -891,9 +981,25 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                     })
                     .collect::<Vec<_>>();
 
+                let branch_diff_task = if self
+                    .source
+                    .supports_context(PromptContextType::BranchDiff, cx)
+                {
+                    self.fetch_branch_diff_match(&workspace, cx)
+                } else {
+                    None
+                };
+
                 cx.spawn(async move |_cx| {
                     let mut matches = recent_task.await;
                     matches.extend(entries);
+
+                    if let Some(branch_diff_task) = branch_diff_task {
+                        if let Some(branch_diff_match) = branch_diff_task.await {
+                            matches.push(Match::BranchDiff(branch_diff_match));
+                        }
+                    }
+
                     matches
                 })
             }
@@ -910,7 +1016,16 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                     .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
                     .collect::<Vec<_>>();
 
-                cx.background_spawn(async move {
+                let branch_diff_task = if self
+                    .source
+                    .supports_context(PromptContextType::BranchDiff, cx)
+                {
+                    self.fetch_branch_diff_match(&workspace, cx)
+                } else {
+                    None
+                };
+
+                cx.spawn(async move |cx| {
                     let mut matches = search_files_task
                         .await
                         .into_iter()
@@ -935,6 +1050,26 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                         })
                     }));
 
+                    if let Some(branch_diff_task) = branch_diff_task {
+                        let branch_diff_keyword = PromptContextType::BranchDiff.keyword();
+                        let branch_diff_matches = fuzzy::match_strings(
+                            &[StringMatchCandidate::new(0, branch_diff_keyword)],
+                            &query,
+                            false,
+                            true,
+                            1,
+                            &Arc::new(AtomicBool::default()),
+                            cx.background_executor().clone(),
+                        )
+                        .await;
+
+                        if !branch_diff_matches.is_empty() {
+                            if let Some(branch_diff_match) = branch_diff_task.await {
+                                matches.push(Match::BranchDiff(branch_diff_match));
+                            }
+                        }
+                    }
+
                     matches.sort_by(|a, b| {
                         b.score()
                             .partial_cmp(&a.score())
@@ -1018,15 +1153,18 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                     .read(cx)
                     .sessions()
                     .into_iter()
+                    .map(|session| SessionMatch {
+                        session_id: session.session_id.clone(),
+                        title: session_title(session.title.clone()),
+                    })
                     .filter(|session| {
                         let uri = MentionUri::Thread {
                             id: session.session_id.clone(),
-                            name: session_title(session).to_string(),
+                            name: session.title.to_string(),
                         };
                         !mentions.contains(&uri)
                     })
                     .take(RECENT_COUNT)
-                    .cloned()
                     .map(Match::RecentThread),
             );
             return Task::ready(recent);
@@ -1298,7 +1436,8 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
                                     )
                                 }
                                 Match::Thread(thread) => Some(Self::completion_for_thread(
-                                    thread,
+                                    thread.session_id,
+                                    Some(thread.title),
                                     source_range.clone(),
                                     false,
                                     source.clone(),
@@ -1308,7 +1447,8 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
                                     cx,
                                 )),
                                 Match::RecentThread(thread) => Some(Self::completion_for_thread(
-                                    thread,
+                                    thread.session_id,
+                                    Some(thread.title),
                                     source_range.clone(),
                                     true,
                                     source.clone(),
@@ -1345,6 +1485,17 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
                                         cx,
                                     )
                                 }
+                                Match::BranchDiff(branch_diff) => {
+                                    Some(Self::build_branch_diff_completion(
+                                        branch_diff.base_ref,
+                                        source_range.clone(),
+                                        source.clone(),
+                                        editor.clone(),
+                                        mention_set.clone(),
+                                        workspace.clone(),
+                                        cx,
+                                    ))
+                                }
                             })
                             .collect::<Vec<_>>()
                     });
@@ -1878,9 +2029,9 @@ pub(crate) fn search_symbols(
 fn filter_sessions_by_query(
     query: String,
     cancellation_flag: Arc<AtomicBool>,
-    sessions: Vec<AgentSessionInfo>,
+    sessions: Vec<SessionMatch>,
     cx: &mut App,
-) -> Task<Vec<AgentSessionInfo>> {
+) -> Task<Vec<SessionMatch>> {
     if query.is_empty() {
         return Task::ready(sessions);
     }
@@ -1893,10 +2044,13 @@ fn filter_sessions_by_query(
 async fn filter_sessions(
     query: String,
     cancellation_flag: Arc<AtomicBool>,
-    sessions: Vec<AgentSessionInfo>,
+    sessions: Vec<SessionMatch>,
     executor: BackgroundExecutor,
-) -> Vec<AgentSessionInfo> {
-    let titles = sessions.iter().map(session_title).collect::<Vec<_>>();
+) -> Vec<SessionMatch> {
+    let titles = sessions
+        .iter()
+        .map(|session| session.title.clone())
+        .collect::<Vec<_>>();
     let candidates = titles
         .iter()
         .enumerate()
@@ -2338,10 +2492,14 @@ mod tests {
 
     #[gpui::test]
     async fn test_filter_sessions_by_query(cx: &mut TestAppContext) {
-        let mut alpha = AgentSessionInfo::new("session-alpha");
-        alpha.title = Some("Alpha Session".into());
-        let mut beta = AgentSessionInfo::new("session-beta");
-        beta.title = Some("Beta Session".into());
+        let alpha = SessionMatch {
+            session_id: acp::SessionId::new("session-alpha"),
+            title: "Alpha Session".into(),
+        };
+        let beta = SessionMatch {
+            session_id: acp::SessionId::new("session-beta"),
+            title: "Beta Session".into(),
+        };
 
         let sessions = vec![alpha.clone(), beta];
 

crates/agent_ui/src/config_options.rs 🔗

@@ -350,10 +350,7 @@ impl ConfigOptionSelector {
         )
         .label_size(LabelSize::Small)
         .color(Color::Muted)
-        .icon(icon)
-        .icon_size(IconSize::XSmall)
-        .icon_position(IconPosition::End)
-        .icon_color(Color::Muted)
+        .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
         .disabled(self.setting_value)
     }
 }

crates/agent_ui/src/connection_view.rs 🔗

@@ -5,10 +5,12 @@ use acp_thread::{
     UserMessageId,
 };
 use acp_thread::{AgentConnection, Plan};
-use action_log::{ActionLog, ActionLogTelemetry};
+use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
 use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore};
 use agent_client_protocol::{self as acp, PromptCapabilities};
-use agent_servers::{AgentServer, AgentServerDelegate};
+use agent_servers::AgentServer;
+#[cfg(test)]
+use agent_servers::AgentServerDelegate;
 use agent_settings::{AgentProfileId, AgentSettings};
 use anyhow::{Result, anyhow};
 use arrayvec::ArrayVec;
@@ -39,12 +41,12 @@ use prompt_store::{PromptId, PromptStore};
 use rope::Point;
 use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
 use std::cell::RefCell;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::time::Instant;
 use std::{collections::BTreeMap, rc::Rc, time::Duration};
 use terminal_view::terminal_panel::TerminalPanel;
-use text::{Anchor, ToPoint as _};
+use text::Anchor;
 use theme::AgentFontSize;
 use ui::{
     Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
@@ -65,18 +67,21 @@ use super::entry_view_state::EntryViewState;
 use super::thread_history::ThreadHistory;
 use crate::ModeSelector;
 use crate::ModelSelectorPopover;
+use crate::agent_connection_store::{
+    AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
+};
 use crate::agent_diff::AgentDiff;
 use crate::entry_view_state::{EntryViewEvent, ViewEvent};
 use crate::message_editor::{MessageEditor, MessageEditorEvent};
 use crate::profile_selector::{ProfileProvider, ProfileSelector};
 use crate::ui::{AgentNotification, AgentNotificationEvent};
 use crate::{
-    AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall,
-    ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort,
-    EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu,
-    OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SendImmediately,
-    SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu,
-    ToggleThinkingMode, UndoLastReject,
+    Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
+    AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector,
+    CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread,
+    OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
+    RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode,
+    ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject,
 };
 
 const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
@@ -303,13 +308,14 @@ impl EventEmitter<AcpServerViewEvent> for ConnectionView {}
 
 pub struct ConnectionView {
     agent: Rc<dyn AgentServer>,
+    connection_store: Entity<AgentConnectionStore>,
+    connection_key: Agent,
     agent_server_store: Entity<AgentServerStore>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
     thread_store: Option<Entity<ThreadStore>>,
     prompt_store: Option<Entity<PromptStore>>,
     server_state: ServerState,
-    history: Entity<ThreadHistory>,
     focus_handle: FocusHandle,
     notifications: Vec<WindowHandle<AgentNotification>>,
     notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
@@ -399,7 +405,10 @@ impl ConnectionView {
 
 enum ServerState {
     Loading(Entity<LoadingView>),
-    LoadError(LoadError),
+    LoadError {
+        error: LoadError,
+        session_id: Option<acp::SessionId>,
+    },
     Connected(ConnectedServerState),
 }
 
@@ -410,7 +419,9 @@ pub struct ConnectedServerState {
     active_id: Option<acp::SessionId>,
     threads: HashMap<acp::SessionId, Entity<ThreadView>>,
     connection: Rc<dyn AgentConnection>,
+    history: Entity<ThreadHistory>,
     conversation: Entity<Conversation>,
+    _connection_entry_subscription: Subscription,
 }
 
 enum AuthState {
@@ -430,9 +441,8 @@ impl AuthState {
 }
 
 struct LoadingView {
-    title: SharedString,
+    session_id: Option<acp::SessionId>,
     _load_task: Task<()>,
-    _update_title_task: Task<anyhow::Result<()>>,
 }
 
 impl ConnectedServerState {
@@ -452,10 +462,13 @@ impl ConnectedServerState {
     }
 
     pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
-        let tasks = self
-            .threads
-            .keys()
-            .map(|id| self.connection.close_session(id, cx));
+        let tasks = self.threads.keys().filter_map(|id| {
+            if self.connection.supports_close_session() {
+                Some(self.connection.clone().close_session(id, cx))
+            } else {
+                None
+            }
+        });
         let task = futures::future::join_all(tasks);
         cx.background_spawn(async move {
             task.await;
@@ -466,13 +479,16 @@ impl ConnectedServerState {
 impl ConnectionView {
     pub fn new(
         agent: Rc<dyn AgentServer>,
-        resume_thread: Option<AgentSessionInfo>,
+        connection_store: Entity<AgentConnectionStore>,
+        connection_key: Agent,
+        resume_session_id: Option<acp::SessionId>,
+        cwd: Option<PathBuf>,
+        title: Option<SharedString>,
         initial_content: Option<AgentInitialContent>,
         workspace: WeakEntity<Workspace>,
         project: Entity<Project>,
         thread_store: Option<Entity<ThreadStore>>,
         prompt_store: Option<Entity<PromptStore>>,
-        history: Entity<ThreadHistory>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -503,6 +519,8 @@ impl ConnectionView {
 
         Self {
             agent: agent.clone(),
+            connection_store: connection_store.clone(),
+            connection_key: connection_key.clone(),
             agent_server_store,
             workspace,
             project: project.clone(),
@@ -510,7 +528,11 @@ impl ConnectionView {
             prompt_store,
             server_state: Self::initial_state(
                 agent.clone(),
-                resume_thread,
+                connection_store,
+                connection_key,
+                resume_session_id,
+                cwd,
+                title,
                 project,
                 initial_content,
                 window,
@@ -519,7 +541,6 @@ impl ConnectionView {
             notifications: Vec::new(),
             notification_subscriptions: HashMap::default(),
             auth_task: None,
-            history,
             _subscriptions: subscriptions,
             focus_handle: cx.focus_handle(),
         }
@@ -536,13 +557,25 @@ impl ConnectionView {
     }
 
     fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let resume_thread_metadata = self
+        let (resume_session_id, cwd, title) = self
             .active_thread()
-            .and_then(|thread| thread.read(cx).resume_thread_metadata.clone());
+            .map(|thread_view| {
+                let thread = thread_view.read(cx).thread.read(cx);
+                (
+                    Some(thread.session_id().clone()),
+                    thread.cwd().cloned(),
+                    Some(thread.title()),
+                )
+            })
+            .unwrap_or((None, None, None));
 
         let state = Self::initial_state(
             self.agent.clone(),
-            resume_thread_metadata,
+            self.connection_store.clone(),
+            self.connection_key.clone(),
+            resume_session_id,
+            cwd,
+            title,
             self.project.clone(),
             None,
             window,
@@ -566,7 +599,11 @@ impl ConnectionView {
 
     fn initial_state(
         agent: Rc<dyn AgentServer>,
-        resume_thread: Option<AgentSessionInfo>,
+        connection_store: Entity<AgentConnectionStore>,
+        connection_key: Agent,
+        resume_session_id: Option<acp::SessionId>,
+        cwd: Option<PathBuf>,
+        title: Option<SharedString>,
         project: Entity<Project>,
         initial_content: Option<AgentInitialContent>,
         window: &mut Window,
@@ -575,9 +612,12 @@ impl ConnectionView {
         if project.read(cx).is_via_collab()
             && agent.clone().downcast::<NativeAgentServer>().is_none()
         {
-            return ServerState::LoadError(LoadError::Other(
-                "External agents are not yet supported in shared projects.".into(),
-            ));
+            return ServerState::LoadError {
+                error: LoadError::Other(
+                    "External agents are not yet supported in shared projects.".into(),
+                ),
+                session_id: resume_session_id.clone(),
+            };
         }
         let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
         // Pick the first non-single-file worktree for the root directory if there are any,
@@ -598,53 +638,53 @@ impl ConnectionView {
                 }
             })
             .collect();
-        let session_cwd = resume_thread
-            .as_ref()
-            .and_then(|resume| {
-                resume
-                    .cwd
-                    .as_ref()
-                    .filter(|cwd| {
-                        // Validate with the normalized path (rejects `..` traversals),
-                        // but return the original cwd to preserve its path separators.
-                        // On Windows, `normalize_lexically` rebuilds the path with
-                        // backslashes via `PathBuf::push`, which would corrupt
-                        // forward-slash Linux paths used by WSL agents.
-                        util::paths::normalize_lexically(cwd)
-                            .ok()
-                            .is_some_and(|normalized| {
-                                worktree_roots
-                                    .iter()
-                                    .any(|root| normalized.starts_with(root.as_ref()))
-                            })
+        let session_cwd = cwd
+            .filter(|cwd| {
+                // Validate with the normalized path (rejects `..` traversals),
+                // but return the original cwd to preserve its path separators.
+                // On Windows, `normalize_lexically` rebuilds the path with
+                // backslashes via `PathBuf::push`, which would corrupt
+                // forward-slash Linux paths used by WSL agents.
+                util::paths::normalize_lexically(cwd)
+                    .ok()
+                    .is_some_and(|normalized| {
+                        worktree_roots
+                            .iter()
+                            .any(|root| normalized.starts_with(root.as_ref()))
                     })
-                    .map(|path| Arc::from(path.as_path()))
             })
+            .map(|path| path.into())
             .or_else(|| worktree_roots.first().cloned())
             .unwrap_or_else(|| paths::home_dir().as_path().into());
 
-        let (status_tx, mut status_rx) = watch::channel("Loading…".into());
-        let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
-        let delegate = AgentServerDelegate::new(
-            project.read(cx).agent_server_store().clone(),
-            project.clone(),
-            Some(status_tx),
-            Some(new_version_available_tx),
-        );
+        let connection_entry = connection_store.update(cx, |store, cx| {
+            store.request_connection(connection_key, agent.clone(), cx)
+        });
+
+        let connection_entry_subscription =
+            cx.subscribe(&connection_entry, |this, _entry, event, cx| match event {
+                AgentConnectionEntryEvent::NewVersionAvailable(version) => {
+                    if let Some(thread) = this.active_thread() {
+                        thread.update(cx, |thread, cx| {
+                            thread.new_server_version_available = Some(version.clone());
+                            cx.notify();
+                        });
+                    }
+                }
+            });
 
-        let connect_task = agent.connect(delegate, cx);
+        let connect_result = connection_entry.read(cx).wait_for_connection();
+
+        let load_session_id = resume_session_id.clone();
         let load_task = cx.spawn_in(window, async move |this, cx| {
-            let connection = match connect_task.await {
-                Ok(connection) => connection,
+            let (connection, history) = match connect_result.await {
+                Ok(AgentConnectedState {
+                    connection,
+                    history,
+                }) => (connection, history),
                 Err(err) => {
                     this.update_in(cx, |this, window, cx| {
-                        if err.downcast_ref::<LoadError>().is_some() {
-                            this.handle_load_error(err, window, cx);
-                        } else if let Some(active) = this.active_thread() {
-                            active.update(cx, |active, cx| active.handle_thread_error(err, cx));
-                        } else {
-                            this.handle_load_error(err, window, cx);
-                        }
+                        this.handle_load_error(load_session_id.clone(), err, window, cx);
                         cx.notify();
                     })
                     .log_err();
@@ -655,17 +695,25 @@ impl ConnectionView {
             telemetry::event!("Agent Thread Started", agent = connection.telemetry_id());
 
             let mut resumed_without_history = false;
-            let result = if let Some(resume) = resume_thread.clone() {
+            let result = if let Some(session_id) = load_session_id.clone() {
                 cx.update(|_, cx| {
                     if connection.supports_load_session() {
-                        connection
-                            .clone()
-                            .load_session(resume, project.clone(), &session_cwd, cx)
+                        connection.clone().load_session(
+                            session_id,
+                            project.clone(),
+                            &session_cwd,
+                            title,
+                            cx,
+                        )
                     } else if connection.supports_resume_session() {
                         resumed_without_history = true;
-                        connection
-                            .clone()
-                            .resume_session(resume, project.clone(), &session_cwd, cx)
+                        connection.clone().resume_session(
+                            session_id,
+                            project.clone(),
+                            &session_cwd,
+                            title,
+                            cx,
+                        )
                     } else {
                         Task::ready(Err(anyhow!(LoadError::Other(
                             "Loading or resuming sessions is not supported by this agent.".into()
@@ -721,8 +769,8 @@ impl ConnectionView {
                             thread,
                             conversation.clone(),
                             resumed_without_history,
-                            resume_thread,
                             initial_content,
+                            history.clone(),
                             window,
                             cx,
                         );
@@ -736,14 +784,6 @@ impl ConnectionView {
                         }
 
                         let id = current.read(cx).thread.read(cx).session_id().clone();
-                        let session_list = if connection.supports_session_history() {
-                            connection.session_list(cx)
-                        } else {
-                            None
-                        };
-                        this.history.update(cx, |history, cx| {
-                            history.set_session_list(session_list, cx);
-                        });
                         this.set_server_state(
                             ServerState::Connected(ConnectedServerState {
                                 connection,
@@ -751,51 +791,28 @@ impl ConnectionView {
                                 active_id: Some(id.clone()),
                                 threads: HashMap::from_iter([(id, current)]),
                                 conversation,
+                                history,
+                                _connection_entry_subscription: connection_entry_subscription,
                             }),
                             cx,
                         );
                     }
                     Err(err) => {
-                        this.handle_load_error(err, window, cx);
+                        this.handle_load_error(
+                            load_session_id.clone(),
+                            LoadError::Other(err.to_string().into()),
+                            window,
+                            cx,
+                        );
                     }
                 };
             })
             .log_err();
         });
 
-        cx.spawn(async move |this, cx| {
-            while let Ok(new_version) = new_version_available_rx.recv().await {
-                if let Some(new_version) = new_version {
-                    this.update(cx, |this, cx| {
-                        if let Some(thread) = this.active_thread() {
-                            thread.update(cx, |thread, _cx| {
-                                thread.new_server_version_available = Some(new_version.into());
-                            });
-                        }
-                        cx.notify();
-                    })
-                    .ok();
-                }
-            }
-        })
-        .detach();
-
-        let loading_view = cx.new(|cx| {
-            let update_title_task = cx.spawn(async move |this, cx| {
-                loop {
-                    let status = status_rx.recv().await?;
-                    this.update(cx, |this: &mut LoadingView, cx| {
-                        this.title = status;
-                        cx.notify();
-                    })?;
-                }
-            });
-
-            LoadingView {
-                title: "Loading…".into(),
-                _load_task: load_task,
-                _update_title_task: update_title_task,
-            }
+        let loading_view = cx.new(|_cx| LoadingView {
+            session_id: resume_session_id,
+            _load_task: load_task,
         });
 
         ServerState::Loading(loading_view)
@@ -807,8 +824,8 @@ impl ConnectionView {
         thread: Entity<AcpThread>,
         conversation: Entity<Conversation>,
         resumed_without_history: bool,
-        resume_thread: Option<AgentSessionInfo>,
         initial_content: Option<AgentInitialContent>,
+        history: Entity<ThreadHistory>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Entity<ThreadView> {
@@ -825,7 +842,7 @@ impl ConnectionView {
                 self.workspace.clone(),
                 self.project.downgrade(),
                 self.thread_store.clone(),
-                self.history.downgrade(),
+                history.downgrade(),
                 self.prompt_store.clone(),
                 prompt_capabilities.clone(),
                 available_commands.clone(),
@@ -990,10 +1007,9 @@ impl ConnectionView {
                 prompt_capabilities,
                 available_commands,
                 resumed_without_history,
-                resume_thread,
                 self.project.downgrade(),
                 self.thread_store.clone(),
-                self.history.clone(),
+                history,
                 self.prompt_store.clone(),
                 initial_content,
                 subscriptions,
@@ -1075,6 +1091,8 @@ impl ConnectionView {
                         threads: HashMap::default(),
                         connection,
                         conversation: cx.new(|_cx| Conversation::default()),
+                        history: cx.new(|cx| ThreadHistory::new(None, cx)),
+                        _connection_entry_subscription: Subscription::new(|| {}),
                     }),
                     cx,
                 );
@@ -1086,7 +1104,8 @@ impl ConnectionView {
 
     fn handle_load_error(
         &mut self,
-        err: anyhow::Error,
+        session_id: Option<acp::SessionId>,
+        err: LoadError,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -1100,13 +1119,14 @@ impl ConnectionView {
                 self.focus_handle.focus(window, cx)
             }
         }
-        let load_error = if let Some(load_err) = err.downcast_ref::<LoadError>() {
-            load_err.clone()
-        } else {
-            LoadError::Other(format!("{:#}", err).into())
-        };
-        self.emit_load_error_telemetry(&load_error);
-        self.set_server_state(ServerState::LoadError(load_error), cx);
+        self.emit_load_error_telemetry(&err);
+        self.set_server_state(
+            ServerState::LoadError {
+                error: err,
+                session_id,
+            },
+            cx,
+        );
     }
 
     fn handle_agent_servers_updated(
@@ -1121,7 +1141,7 @@ impl ConnectionView {
         // This handles the case where a thread is restored before authentication completes.
         let should_retry = match &self.server_state {
             ServerState::Loading(_) => false,
-            ServerState::LoadError(_) => true,
+            ServerState::LoadError { .. } => true,
             ServerState::Connected(connected) => {
                 connected.auth_state.is_ok() && connected.has_thread_error(cx)
             }
@@ -1141,11 +1161,11 @@ impl ConnectionView {
         &self.workspace
     }
 
-    pub fn title(&self, cx: &App) -> SharedString {
+    pub fn title(&self, _cx: &App) -> SharedString {
         match &self.server_state {
             ServerState::Connected(_) => "New Thread".into(),
-            ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(),
-            ServerState::LoadError(error) => match error {
+            ServerState::Loading(_) => "Loading…".into(),
+            ServerState::LoadError { error, .. } => match error {
                 LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
                 LoadError::FailedToInstall(_) => {
                     format!("Failed to Install {}", self.agent.name()).into()
@@ -1164,6 +1184,17 @@ impl ConnectionView {
         }
     }
 
+    // The parent ID is None if we haven't created a thread yet
+    pub fn parent_id(&self, cx: &App) -> Option<acp::SessionId> {
+        match &self.server_state {
+            ServerState::Connected(_) => self
+                .parent_thread(cx)
+                .map(|thread| thread.read(cx).id.clone()),
+            ServerState::Loading(loading) => loading.read(cx).session_id.clone(),
+            ServerState::LoadError { session_id, .. } => session_id.clone(),
+        }
+    }
+
     pub fn is_loading(&self) -> bool {
         matches!(self.server_state, ServerState::Loading { .. })
     }
@@ -1361,7 +1392,13 @@ impl ConnectionView {
                         self.focus_handle.focus(window, cx)
                     }
                 }
-                self.set_server_state(ServerState::LoadError(error.clone()), cx);
+                self.set_server_state(
+                    ServerState::LoadError {
+                        error: error.clone(),
+                        session_id: Some(thread_id),
+                    },
+                    cx,
+                );
             }
             AcpThreadEvent::TitleUpdated => {
                 let title = thread.read(cx).title();
@@ -1396,7 +1433,7 @@ impl ConnectionView {
                     .connection()
                     .auth_methods()
                     .iter()
-                    .any(|method| method.id.0.as_ref() == "claude-login")
+                    .any(|method| method.id().0.as_ref() == "claude-login")
                 {
                     available_commands.push(acp::AvailableCommand::new("login", "Authenticate"));
                     available_commands.push(acp::AvailableCommand::new("logout", "Authenticate"));
@@ -1460,10 +1497,15 @@ impl ConnectionView {
         let agent_telemetry_id = connection.telemetry_id();
 
         // Check for the experimental "terminal-auth" _meta field
-        let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
+        let auth_method = connection.auth_methods().iter().find(|m| m.id() == &method);
 
         if let Some(terminal_auth) = auth_method
-            .and_then(|a| a.meta.as_ref())
+            .and_then(|a| match a {
+                acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(),
+                acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(),
+                acp::AuthMethod::Agent(agent) => agent.meta.as_ref(),
+                _ => None,
+            })
             .and_then(|m| m.get("terminal-auth"))
         {
             // Extract terminal auth details from meta
@@ -1644,19 +1686,20 @@ impl ConnectionView {
         let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into());
 
         let subagent_thread_task = connected.connection.clone().load_session(
-            AgentSessionInfo::new(subagent_id.clone()),
+            subagent_id.clone(),
             self.project.clone(),
             &cwd,
+            None,
             cx,
         );
 
         cx.spawn_in(window, async move |this, cx| {
             let subagent_thread = subagent_thread_task.await?;
             this.update_in(cx, |this, window, cx| {
-                let conversation = this
+                let Some((conversation, history)) = this
                     .as_connected()
-                    .map(|connected| connected.conversation.clone());
-                let Some(conversation) = conversation else {
+                    .map(|connected| (connected.conversation.clone(), connected.history.clone()))
+                else {
                     return;
                 };
                 conversation.update(cx, |conversation, cx| {
@@ -1668,7 +1711,7 @@ impl ConnectionView {
                     conversation,
                     false,
                     None,
-                    None,
+                    history,
                     window,
                     cx,
                 );
@@ -1847,7 +1890,7 @@ impl ConnectionView {
                     .enumerate()
                     .rev()
                     .map(|(ix, method)| {
-                        let (method_id, name) = (method.id.0.clone(), method.name.clone());
+                        let (method_id, name) = (method.id().0.clone(), method.name().to_string());
                         let agent_telemetry_id = connection.telemetry_id();
 
                         Button::new(method_id.clone(), name)
@@ -1859,8 +1902,8 @@ impl ConnectionView {
                                     this.style(ButtonStyle::Outlined)
                                 }
                             })
-                            .when_some(method.description.clone(), |this, description| {
-                                this.tooltip(Tooltip::text(description))
+                            .when_some(method.description(), |this, description| {
+                                this.tooltip(Tooltip::text(description.to_string()))
                             })
                             .on_click({
                                 cx.listener(move |this, _, window, cx| {
@@ -2175,9 +2218,11 @@ impl ConnectionView {
         let agent_name = self.agent.name();
         let workspace = self.workspace.clone();
         let project = self.project.downgrade();
-        let history = self.history.downgrade();
-
-        let Some(thread) = self.active_thread() else {
+        let Some(connected) = self.as_connected() else {
+            return;
+        };
+        let history = connected.history.downgrade();
+        let Some(thread) = connected.active_view() else {
             return;
         };
         let prompt_capabilities = thread.read(cx).prompt_capabilities.clone();
@@ -2305,7 +2350,7 @@ impl ConnectionView {
         }
 
         if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
-            multi_workspace.read(cx).is_sidebar_open()
+            crate::agent_panel::sidebar_is_open(window, cx)
                 || self.agent_panel_visible(&multi_workspace, cx)
         } else {
             self.workspace
@@ -2570,10 +2615,18 @@ impl ConnectionView {
         })
     }
 
-    pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context<Self>) {
-        let task = self.history.update(cx, |history, cx| {
-            history.delete_session(&entry.session_id, cx)
-        });
+    pub fn history(&self) -> Option<&Entity<ThreadHistory>> {
+        self.as_connected().map(|c| &c.history)
+    }
+
+    pub fn delete_history_entry(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+        let Some(connected) = self.as_connected() else {
+            return;
+        };
+
+        let task = connected
+            .history
+            .update(cx, |history, cx| history.delete_session(&session_id, cx));
         task.detach_and_log_err(cx);
     }
 }
@@ -2625,6 +2678,7 @@ impl ConnectionView {
 impl Render for ConnectionView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         self.sync_queued_message_editors(window, cx);
+        let v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
 
         v_flex()
             .track_focus(&self.focus_handle)
@@ -2633,9 +2687,19 @@ impl Render for ConnectionView {
             .child(match &self.server_state {
                 ServerState::Loading { .. } => v_flex()
                     .flex_1()
-                    // .child(self.render_recent_history(cx))
+                    .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),
+                            ),
+                        )
+                    })
                     .into_any(),
-                ServerState::LoadError(e) => v_flex()
+                ServerState::LoadError { error: e, .. } => v_flex()
                     .flex_1()
                     .size_full()
                     .items_center()
@@ -2737,6 +2801,55 @@ pub(crate) mod tests {
         assert!(!weak_view.is_upgradable());
     }
 
+    #[gpui::test]
+    async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
+            panic!("expected prompt from external source to sanitize successfully");
+        };
+        let initial_content = AgentInitialContent::FromExternalSource(prompt);
+
+        let (thread_view, cx) = setup_thread_view_with_initial_content(
+            StubAgentServer::default_response(),
+            initial_content,
+            cx,
+        )
+        .await;
+
+        active_thread(&thread_view, cx).read_with(cx, |view, cx| {
+            assert!(view.show_external_source_prompt_warning);
+            assert_eq!(view.thread.read(cx).entries().len(), 0);
+            assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_external_source_prompt_warning_clears_after_send(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let Some(prompt) = crate::ExternalSourcePrompt::new("Write me a script") else {
+            panic!("expected prompt from external source to sanitize successfully");
+        };
+        let initial_content = AgentInitialContent::FromExternalSource(prompt);
+
+        let (thread_view, cx) = setup_thread_view_with_initial_content(
+            StubAgentServer::default_response(),
+            initial_content,
+            cx,
+        )
+        .await;
+
+        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
+        cx.run_until_parked();
+
+        active_thread(&thread_view, cx).read_with(cx, |view, cx| {
+            assert!(!view.show_external_source_prompt_warning);
+            assert_eq!(view.message_editor.read(cx).text(cx), "");
+            assert_eq!(view.thread.read(cx).entries().len(), 2);
+        });
+    }
+
     #[gpui::test]
     async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
         init_test(cx);
@@ -2800,20 +2913,25 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        // Create history without an initial session list - it will be set after connection
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::default_response()),
+                    connection_store,
+                    Agent::Custom {
+                        name: "Test".into(),
+                    },
+                    None,
+                    None,
                     None,
                     None,
                     workspace.downgrade(),
                     project,
                     Some(thread_store),
                     None,
-                    history.clone(),
                     window,
                     cx,
                 )
@@ -2823,6 +2941,14 @@ pub(crate) mod tests {
         // Wait for connection to establish
         cx.run_until_parked();
 
+        let history = cx.update(|_window, cx| {
+            thread_view
+                .read(cx)
+                .history()
+                .expect("Missing history")
+                .clone()
+        });
+
         // Initially empty because StubAgentConnection.session_list() returns None
         active_thread(&thread_view, cx).read_with(cx, |view, _cx| {
             assert_eq!(view.recent_history_entries.len(), 0);
@@ -2892,7 +3018,6 @@ pub(crate) mod tests {
     async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
         init_test(cx);
 
-        let session = AgentSessionInfo::new(SessionId::new("resume-session"));
         let fs = FakeFs::new(cx.executor());
         let project = Project::test(fs, [], cx).await;
         let (multi_workspace, cx) =
@@ -2900,19 +3025,25 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
-                    Some(session),
+                    connection_store,
+                    Agent::Custom {
+                        name: "Test".into(),
+                    },
+                    Some(SessionId::new("resume-session")),
+                    None,
+                    None,
                     None,
                     workspace.downgrade(),
                     project,
                     Some(thread_store),
                     None,
-                    history,
                     window,
                     cx,
                 )
@@ -2950,23 +3081,26 @@ pub(crate) mod tests {
         let connection = CwdCapturingConnection::new();
         let captured_cwd = connection.captured_cwd.clone();
 
-        let mut session = AgentSessionInfo::new(SessionId::new("session-1"));
-        session.cwd = Some(PathBuf::from("/project/subdir"));
-
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let _thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(connection)),
-                    Some(session),
+                    connection_store,
+                    Agent::Custom {
+                        name: "Test".into(),
+                    },
+                    Some(SessionId::new("session-1")),
+                    Some(PathBuf::from("/project/subdir")),
+                    None,
                     None,
                     workspace.downgrade(),
                     project,
                     Some(thread_store),
                     None,
-                    history,
                     window,
                     cx,
                 )
@@ -3002,23 +3136,26 @@ pub(crate) mod tests {
         let connection = CwdCapturingConnection::new();
         let captured_cwd = connection.captured_cwd.clone();
 
-        let mut session = AgentSessionInfo::new(SessionId::new("session-1"));
-        session.cwd = Some(PathBuf::from("/some/other/path"));
-
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let _thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(connection)),
-                    Some(session),
+                    connection_store,
+                    Agent::Custom {
+                        name: "Test".into(),
+                    },
+                    Some(SessionId::new("session-1")),
+                    Some(PathBuf::from("/some/other/path")),
+                    None,
                     None,
                     workspace.downgrade(),
                     project,
                     Some(thread_store),
                     None,
-                    history,
                     window,
                     cx,
                 )
@@ -3054,23 +3191,26 @@ pub(crate) mod tests {
         let connection = CwdCapturingConnection::new();
         let captured_cwd = connection.captured_cwd.clone();
 
-        let mut session = AgentSessionInfo::new(SessionId::new("session-1"));
-        session.cwd = Some(PathBuf::from("/project/../outside"));
-
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
         let _thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(StubAgentServer::new(connection)),
-                    Some(session),
+                    connection_store,
+                    Agent::Custom {
+                        name: "Test".into(),
+                    },
+                    Some(SessionId::new("session-1")),
+                    Some(PathBuf::from("/project/../outside")),
+                    None,
                     None,
                     workspace.downgrade(),
                     project,
                     Some(thread_store),
                     None,
-                    history,
                     window,
                     cx,
                 )
@@ -3126,7 +3266,10 @@ pub(crate) mod tests {
                 "Tab title should show the agent name with an error prefix"
             );
             match &view.server_state {
-                ServerState::LoadError(LoadError::Other(msg)) => {
+                ServerState::LoadError {
+                    error: LoadError::Other(msg),
+                    ..
+                } => {
                     assert!(
                         msg.contains("Invalid gzip header"),
                         "Error callout should contain the underlying extraction error, got: {msg}"
@@ -3136,7 +3279,7 @@ pub(crate) mod tests {
                     "Expected LoadError::Other, got: {}",
                     match other {
                         ServerState::Loading(_) => "Loading (stuck!)",
-                        ServerState::LoadError(_) => "LoadError (wrong variant)",
+                        ServerState::LoadError { .. } => "LoadError (wrong variant)",
                         ServerState::Connected(_) => "Connected",
                     }
                 ),
@@ -3365,20 +3508,26 @@ pub(crate) mod tests {
 
         // Set up thread view in workspace 1
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
 
         let agent = StubAgentServer::default_response();
         let thread_view = cx.update(|window, cx| {
             cx.new(|cx| {
                 ConnectionView::new(
                     Rc::new(agent),
+                    connection_store,
+                    Agent::Custom {
+                        name: "Test".into(),
+                    },
+                    None,
+                    None,
                     None,
                     None,
                     workspace1.downgrade(),
                     project1.clone(),
                     Some(thread_store),
                     None,
-                    history,
                     window,
                     cx,
                 )

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

@@ -156,43 +156,6 @@ impl ThreadFeedbackState {
     }
 }
 
-#[derive(Default, Clone, Copy)]
-struct DiffStats {
-    lines_added: u32,
-    lines_removed: u32,
-}
-
-impl DiffStats {
-    fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
-        let mut stats = DiffStats::default();
-        let diff_snapshot = diff.snapshot(cx);
-        let buffer_snapshot = buffer.snapshot();
-        let base_text = diff_snapshot.base_text();
-
-        for hunk in diff_snapshot.hunks(&buffer_snapshot) {
-            let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
-            stats.lines_added += added_rows;
-
-            let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
-            let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
-            let removed_rows = base_end.saturating_sub(base_start);
-            stats.lines_removed += removed_rows;
-        }
-
-        stats
-    }
-
-    fn all_files(changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>, cx: &App) -> Self {
-        let mut total = DiffStats::default();
-        for (buffer, diff) in changed_buffers {
-            let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
-            total.lines_added += stats.lines_added;
-            total.lines_removed += stats.lines_removed;
-        }
-        total
-    }
-}
-
 pub enum AcpThreadViewEvent {
     FirstSendRequested { content: Vec<acp::ContentBlock> },
 }
@@ -247,7 +210,6 @@ pub struct ThreadView {
     pub is_loading_contents: bool,
     pub new_server_version_available: Option<SharedString>,
     pub resumed_without_history: bool,
-    pub resume_thread_metadata: Option<AgentSessionInfo>,
     pub _cancel_task: Option<Task<()>>,
     _save_task: Option<Task<()>>,
     _draft_resolve_task: Option<Task<()>>,
@@ -263,6 +225,7 @@ pub struct ThreadView {
     pub project: WeakEntity<Project>,
     pub recent_history_entries: Vec<AgentSessionInfo>,
     pub hovered_recent_history_item: Option<usize>,
+    pub show_external_source_prompt_warning: bool,
     pub show_codex_windows_warning: bool,
     pub history: Entity<ThreadHistory>,
     pub _history_subscription: Subscription,
@@ -307,7 +270,6 @@ impl ThreadView {
         prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
         available_commands: Rc<RefCell<Vec<agent_client_protocol::AvailableCommand>>>,
         resumed_without_history: bool,
-        resume_thread_metadata: Option<AgentSessionInfo>,
         project: WeakEntity<Project>,
         thread_store: Option<Entity<ThreadStore>>,
         history: Entity<ThreadHistory>,
@@ -326,6 +288,7 @@ impl ThreadView {
         });
 
         let mut should_auto_submit = false;
+        let mut show_external_source_prompt_warning = false;
 
         let message_editor = cx.new(|cx| {
             let mut editor = MessageEditor::new(
@@ -347,8 +310,8 @@ impl ThreadView {
             );
             if let Some(content) = initial_content {
                 match content {
-                    AgentInitialContent::ThreadSummary(entry) => {
-                        editor.insert_thread_summary(entry, window, cx);
+                    AgentInitialContent::ThreadSummary { session_id, title } => {
+                        editor.insert_thread_summary(session_id, title, window, cx);
                     }
                     AgentInitialContent::ContentBlock {
                         blocks,
@@ -357,6 +320,18 @@ impl ThreadView {
                         should_auto_submit = auto_submit;
                         editor.set_message(blocks, window, cx);
                     }
+                    AgentInitialContent::FromExternalSource(prompt) => {
+                        show_external_source_prompt_warning = true;
+                        // SECURITY: Be explicit about not auto submitting prompt from external source.
+                        should_auto_submit = false;
+                        editor.set_message(
+                            vec![acp::ContentBlock::Text(acp::TextContent::new(
+                                prompt.into_string(),
+                            ))],
+                            window,
+                            cx,
+                        );
+                    }
                 }
             } else if let Some(draft) = thread.read(cx).draft_prompt() {
                 editor.set_message(draft.to_vec(), window, cx);
@@ -439,7 +414,6 @@ impl ThreadView {
             prompt_capabilities,
             available_commands,
             resumed_without_history,
-            resume_thread_metadata,
             _subscriptions: subscriptions,
             permission_dropdown_handle: PopoverMenuHandle::default(),
             thread_retry_status: None,
@@ -480,6 +454,7 @@ impl ThreadView {
             project,
             recent_history_entries,
             hovered_recent_history_item: None,
+            show_external_source_prompt_warning,
             history,
             _history_subscription: history_subscription,
             show_codex_windows_warning,
@@ -784,6 +759,13 @@ impl ThreadView {
 
     // sending
 
+    fn clear_external_source_prompt_warning(&mut self, cx: &mut Context<Self>) {
+        if self.show_external_source_prompt_warning {
+            self.show_external_source_prompt_warning = false;
+            cx.notify();
+        }
+    }
+
     pub fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let thread = &self.thread;
 
@@ -865,6 +847,7 @@ impl ThreadView {
                     .any(|command| command.name == "logout");
             if can_login && !logout_supported {
                 message_editor.update(cx, |editor, cx| editor.clear(window, cx));
+                self.clear_external_source_prompt_warning(cx);
 
                 let connection = self.thread.read(cx).connection().clone();
                 window.defer(cx, {
@@ -957,6 +940,7 @@ impl ThreadView {
             };
 
             let generation = this.update(cx, |this, cx| {
+                this.clear_external_source_prompt_warning(cx);
                 let generation = this.start_turn(cx);
                 this.in_flight_prompt = Some(contents.clone());
                 generation
@@ -990,10 +974,9 @@ impl ThreadView {
                 let text = text.lines().next().unwrap_or("").trim();
                 if !text.is_empty() {
                     let title: SharedString = util::truncate_and_trailoff(text, 20).into();
-                    thread
-                        .update(cx, |thread, cx| thread.set_title(title, cx))?
-                        .await
-                        .log_err();
+                    thread.update(cx, |thread, cx| {
+                        thread.set_provisional_title(title, cx);
+                    })?;
                 }
             }
 
@@ -1444,6 +1427,13 @@ impl ThreadView {
 
         match event {
             EditorEvent::BufferEdited => {
+                // We only want to set the title if the user has actively edited
+                // it. If the title editor is not focused, we programmatically
+                // changed the text, so we don't want to set the title again.
+                if !title_editor.read(cx).is_focused(window) {
+                    return;
+                }
+
                 let new_title = title_editor.read(cx).text(cx);
                 thread.update(cx, |thread, cx| {
                     thread
@@ -1773,18 +1763,7 @@ impl ThreadView {
                 })
                 .await?;
 
-            let thread_metadata = AgentSessionInfo {
-                session_id,
-                cwd: None,
-                title: Some(format!("🔗 {}", response.title).into()),
-                updated_at: Some(chrono::Utc::now()),
-                meta: None,
-            };
-
-            this.update_in(cx, |this, window, cx| {
-                this.resume_thread_metadata = Some(thread_metadata);
-                server_view.update(cx, |server_view, cx| server_view.reset(window, cx));
-            })?;
+            server_view.update_in(cx, |server_view, window, cx| server_view.reset(window, cx))?;
 
             this.update_in(cx, |this, _window, cx| {
                 if let Some(workspace) = this.workspace.upgrade() {
@@ -2695,62 +2674,102 @@ impl ThreadView {
             return div().into_any_element();
         }
 
+        let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle;
+        if let Some(model_selector) = &self.model_selector {
+            model_selector.update(cx, |selector, _| selector.set_disabled(is_generating));
+        }
+        if let Some(profile_selector) = &self.profile_selector {
+            profile_selector.update(cx, |selector, _| selector.set_disabled(is_generating));
+        }
+
         let focus_handle = self.message_editor.focus_handle(cx);
         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::<AgentV2FeatureFlag>() && !has_messages;
         let (expand_icon, expand_tooltip) = if editor_expanded {
             (IconName::Minimize, "Minimize Message Editor")
         } else {
             (IconName::Maximize, "Expand Message Editor")
         };
 
+        if v2_empty_state {
+            self.message_editor.update(cx, |editor, cx| {
+                editor.set_mode(
+                    EditorMode::Full {
+                        scale_ui_elements_with_buffer_font_size: false,
+                        show_active_line_background: false,
+                        sizing_behavior: SizingBehavior::Default,
+                    },
+                    cx,
+                );
+            });
+        } else {
+            self.message_editor.update(cx, |editor, cx| {
+                editor.set_mode(
+                    EditorMode::AutoHeight {
+                        min_lines: AgentSettings::get_global(cx).message_editor_min_lines,
+                        max_lines: Some(
+                            AgentSettings::get_global(cx).set_message_editor_max_lines(),
+                        ),
+                    },
+                    cx,
+                );
+            });
+        }
+
         v_flex()
             .on_action(cx.listener(Self::expand_message_editor))
             .p_2()
             .gap_2()
-            .border_t_1()
-            .border_color(cx.theme().colors().border)
+            .when(!v2_empty_state, |this| {
+                this.border_t_1().border_color(cx.theme().colors().border)
+            })
             .bg(editor_bg_color)
-            .when(editor_expanded, |this| {
+            .when(v2_empty_state, |this| this.flex_1().size_full())
+            .when(editor_expanded && !v2_empty_state, |this| {
                 this.h(vh(0.8, window)).size_full().justify_between()
             })
             .child(
                 v_flex()
                     .relative()
                     .size_full()
+                    .when(v2_empty_state, |this| this.flex_1())
                     .pt_1()
                     .pr_2p5()
                     .child(self.message_editor.clone())
-                    .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,
+                    .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,
-                                                &focus_handle,
+                                                window,
                                                 cx,
-                                            )
-                                        }
-                                    })
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.expand_message_editor(
-                                            &ExpandMessageEditor,
-                                            window,
-                                            cx,
-                                        );
-                                    })),
-                            ),
-                    ),
+                                            );
+                                        })),
+                                ),
+                        )
+                    }),
             )
             .child(
                 h_flex()
@@ -3212,6 +3231,7 @@ impl ThreadView {
             return None;
         }
 
+        let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle;
         let thinking = thread.thinking_enabled();
 
         let (tooltip_label, icon, color) = if thinking {
@@ -3233,8 +3253,13 @@ impl ThreadView {
         let thinking_toggle = IconButton::new("thinking-mode", icon)
             .icon_size(IconSize::Small)
             .icon_color(color)
-            .tooltip(move |_, cx| {
-                Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx)
+            .disabled(is_generating)
+            .tooltip(move |window, cx| {
+                if is_generating {
+                    Tooltip::text("Disabled until generation is done")(window, cx)
+                } else {
+                    Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx)
+                }
             })
             .on_click(cx.listener(move |this, _, _window, cx| {
                 if let Some(thread) = this.as_native_thread(cx) {
@@ -3266,6 +3291,7 @@ impl ThreadView {
         let right_btn = self.render_effort_selector(
             model.supported_effort_levels(),
             thread.thinking_effort().cloned(),
+            is_generating,
             cx,
         );
 
@@ -3280,6 +3306,7 @@ impl ThreadView {
         &self,
         supported_effort_levels: Vec<LanguageModelEffortLevel>,
         selected_effort: Option<String>,
+        disabled: bool,
         cx: &Context<Self>,
     ) -> impl IntoElement {
         let weak_self = cx.weak_entity();
@@ -3348,6 +3375,7 @@ impl ThreadView {
         PopoverMenu::new("effort-selector")
             .trigger_with_tooltip(
                 ButtonLike::new_rounded_right("effort-selector-trigger")
+                    .disabled(disabled)
                     .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                     .child(Label::new(label).size(LabelSize::Small).color(label_color))
                     .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
@@ -3529,6 +3557,7 @@ impl ThreadView {
         let message_editor = self.message_editor.clone();
         let workspace = self.workspace.clone();
         let supports_images = self.prompt_capabilities.borrow().image;
+        let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
 
         let has_editor_selection = workspace
             .upgrade()
@@ -3644,6 +3673,20 @@ impl ThreadView {
                             }
                         }),
                 )
+                .item(
+                    ContextMenuEntry::new("Branch Diff")
+                        .icon(IconName::GitBranch)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .disabled(!supports_embedded_context)
+                        .handler({
+                            move |window, cx| {
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.insert_branch_diff_crease(window, cx);
+                                });
+                            }
+                        }),
+                )
         })
     }
 
@@ -3783,11 +3826,8 @@ impl ThreadView {
                                 .child(Divider::horizontal())
                                 .child(
                                     Button::new("restore-checkpoint", "Restore Checkpoint")
-                                        .icon(IconName::Undo)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(Icon::new(IconName::Undo).size(IconSize::XSmall).color(Color::Muted))
                                         .label_size(LabelSize::XSmall)
-                                        .icon_color(Color::Muted)
                                         .color(Color::Muted)
                                         .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
                                         .on_click(cx.listener(move |this, _, _window, cx| {
@@ -4829,6 +4869,7 @@ impl ThreadView {
         cx: &Context<Self>,
     ) -> Div {
         v_flex()
+            .group(group.clone())
             .p_1p5()
             .bg(self.tool_card_header_bg(cx))
             .when(is_preview, |this| {
@@ -5739,10 +5780,11 @@ impl ThreadView {
                     .gap_0p5()
                     .child(
                         Button::new(("allow-btn", entry_ix), "Allow")
-                            .icon(IconName::Check)
-                            .icon_color(Color::Success)
-                            .icon_position(IconPosition::Start)
-                            .icon_size(IconSize::XSmall)
+                            .start_icon(
+                                Icon::new(IconName::Check)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Success),
+                            )
                             .label_size(LabelSize::Small)
                             .when(is_first, |this| {
                                 this.key_binding(
@@ -5773,10 +5815,11 @@ impl ThreadView {
                     )
                     .child(
                         Button::new(("deny-btn", entry_ix), "Deny")
-                            .icon(IconName::Close)
-                            .icon_color(Color::Error)
-                            .icon_position(IconPosition::Start)
-                            .icon_size(IconSize::XSmall)
+                            .start_icon(
+                                Icon::new(IconName::Close)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
+                            )
                             .label_size(LabelSize::Small)
                             .when(is_first, |this| {
                                 this.key_binding(
@@ -5843,9 +5886,11 @@ impl ThreadView {
             .with_handle(permission_dropdown_handle)
             .trigger(
                 Button::new(("granularity-trigger", entry_ix), current_label)
-                    .icon(IconName::ChevronDown)
-                    .icon_size(IconSize::XSmall)
-                    .icon_color(Color::Muted)
+                    .end_icon(
+                        Icon::new(IconName::ChevronDown)
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small)
                     .when(is_first, |this| {
                         this.key_binding(
@@ -5918,24 +5963,35 @@ impl ThreadView {
                 let option_id = SharedString::from(option.option_id.0.clone());
                 Button::new((option_id, entry_ix), option.name.clone())
                     .map(|this| {
-                        let (this, action) = match option.kind {
+                        let (icon, action) = match option.kind {
                             acp::PermissionOptionKind::AllowOnce => (
-                                this.icon(IconName::Check).icon_color(Color::Success),
+                                Icon::new(IconName::Check)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Success),
                                 Some(&AllowOnce as &dyn Action),
                             ),
                             acp::PermissionOptionKind::AllowAlways => (
-                                this.icon(IconName::CheckDouble).icon_color(Color::Success),
+                                Icon::new(IconName::CheckDouble)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Success),
                                 Some(&AllowAlways as &dyn Action),
                             ),
                             acp::PermissionOptionKind::RejectOnce => (
-                                this.icon(IconName::Close).icon_color(Color::Error),
+                                Icon::new(IconName::Close)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
                                 Some(&RejectOnce as &dyn Action),
                             ),
-                            acp::PermissionOptionKind::RejectAlways | _ => {
-                                (this.icon(IconName::Close).icon_color(Color::Error), None)
-                            }
+                            acp::PermissionOptionKind::RejectAlways | _ => (
+                                Icon::new(IconName::Close)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
+                                None,
+                            ),
                         };
 
+                        let this = this.start_icon(icon);
+
                         let Some(action) = action else {
                             return this;
                         };
@@ -5951,8 +6007,6 @@ impl ThreadView {
                                 .map(|kb| kb.size(rems_from_px(10.))),
                         )
                     })
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::XSmall)
                     .label_size(LabelSize::Small)
                     .on_click(cx.listener({
                         let session_id = session_id.clone();
@@ -6329,9 +6383,11 @@ impl ThreadView {
                     .color(Color::Muted)
                     .truncate(true)
                     .when(is_file.is_none(), |this| {
-                        this.icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::XSmall)
-                            .icon_color(Color::Muted)
+                        this.end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                     })
                     .on_click(cx.listener({
                         let workspace = self.workspace.clone();
@@ -7397,7 +7453,7 @@ impl ThreadView {
                                     // TODO: Add keyboard navigation.
                                     let is_hovered =
                                         self.hovered_recent_history_item == Some(index);
-                                    crate::thread_history::HistoryEntryElement::new(
+                                    crate::thread_history_view::HistoryEntryElement::new(
                                         entry,
                                         self.server_view.clone(),
                                     )
@@ -7426,28 +7482,45 @@ impl ThreadView {
             .title("Codex on Windows")
             .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
             .actions_slot(
-                Button::new("open-wsl-modal", "Open in WSL")
+                Button::new("open-wsl-modal", "Open in WSL").on_click(cx.listener({
+                    move |_, _, _window, cx| {
+                        #[cfg(windows)]
+                        _window.dispatch_action(
+                            zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
+                            cx,
+                        );
+                        cx.notify();
+                    }
+                })),
+            )
+            .dismiss_action(
+                IconButton::new("dismiss", IconName::Close)
                     .icon_size(IconSize::Small)
                     .icon_color(Color::Muted)
+                    .tooltip(Tooltip::text("Dismiss Warning"))
                     .on_click(cx.listener({
-                        move |_, _, _window, cx| {
-                            #[cfg(windows)]
-                            _window.dispatch_action(
-                                zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
-                                cx,
-                            );
+                        move |this, _, _, cx| {
+                            this.show_codex_windows_warning = false;
                             cx.notify();
                         }
                     })),
             )
+    }
+
+    fn render_external_source_prompt_warning(&self, cx: &mut Context<Self>) -> Callout {
+        Callout::new()
+            .icon(IconName::Warning)
+            .severity(Severity::Warning)
+            .title("Review before sending")
+            .description("This prompt was pre-filled by an external link. Read it carefully before you send it.")
             .dismiss_action(
-                IconButton::new("dismiss", IconName::Close)
+                IconButton::new("dismiss-external-source-prompt-warning", IconName::Close)
                     .icon_size(IconSize::Small)
                     .icon_color(Color::Muted)
                     .tooltip(Tooltip::text("Dismiss Warning"))
                     .on_click(cx.listener({
                         move |this, _, _, cx| {
-                            this.show_codex_windows_warning = false;
+                            this.show_external_source_prompt_warning = false;
                             cx.notify();
                         }
                     })),
@@ -7640,20 +7713,25 @@ impl ThreadView {
 impl Render for ThreadView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let has_messages = self.list_state.item_count() > 0;
+        let v2_empty_state = cx.has_flag::<AgentV2FeatureFlag>() && !has_messages;
 
-        let conversation = v_flex().flex_1().map(|this| {
-            let this = this.when(self.resumed_without_history, |this| {
-                this.child(Self::render_resume_notice(cx))
+        let conversation = v_flex()
+            .when(!v2_empty_state, |this| this.flex_1())
+            .map(|this| {
+                let this = this.when(self.resumed_without_history, |this| {
+                    this.child(Self::render_resume_notice(cx))
+                });
+                if has_messages {
+                    let list_state = self.list_state.clone();
+                    this.child(self.render_entries(cx))
+                        .vertical_scrollbar_for(&list_state, window, cx)
+                        .into_any()
+                } else if v2_empty_state {
+                    this.into_any()
+                } else {
+                    this.child(self.render_recent_history(cx)).into_any()
+                }
             });
-            if has_messages {
-                let list_state = self.list_state.clone();
-                this.child(self.render_entries(cx))
-                    .vertical_scrollbar_for(&list_state, window, cx)
-                    .into_any()
-            } else {
-                this.child(self.render_recent_history(cx)).into_any()
-            }
-        });
 
         v_flex()
             .key_context("AcpThread")
@@ -7685,6 +7763,9 @@ impl Render for ThreadView {
                 this.toggle_fast_mode(cx);
             }))
             .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| {
+                if this.thread.read(cx).status() != ThreadStatus::Idle {
+                    return;
+                }
                 if let Some(thread) = this.as_native_thread(cx) {
                     thread.update(cx, |thread, cx| {
                         thread.set_thinking_enabled(!thread.thinking_enabled(), cx);
@@ -7692,9 +7773,19 @@ impl Render for ThreadView {
                 }
             }))
             .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| {
+                if this.thread.read(cx).status() != ThreadStatus::Idle {
+                    return;
+                }
                 this.cycle_thinking_effort(cx);
             }))
-            .on_action(cx.listener(Self::toggle_thinking_effort_menu))
+            .on_action(
+                cx.listener(|this, action: &ToggleThinkingEffortMenu, window, cx| {
+                    if this.thread.read(cx).status() != ThreadStatus::Idle {
+                        return;
+                    }
+                    this.toggle_thinking_effort_menu(action, window, cx);
+                }),
+            )
             .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
                 this.send_queued_message_at_index(0, true, window, cx);
             }))
@@ -7712,6 +7803,9 @@ impl Render for ThreadView {
                 cx.notify();
             }))
             .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
+                if this.thread.read(cx).status() != ThreadStatus::Idle {
+                    return;
+                }
                 if let Some(config_options_view) = this.config_options_view.clone() {
                     let handled = config_options_view.update(cx, |view, cx| {
                         view.toggle_category_picker(
@@ -7732,6 +7826,9 @@ impl Render for ThreadView {
                 }
             }))
             .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
+                if this.thread.read(cx).status() != ThreadStatus::Idle {
+                    return;
+                }
                 if let Some(config_options_view) = this.config_options_view.clone() {
                     let handled = config_options_view.update(cx, |view, cx| {
                         view.cycle_category_option(
@@ -7756,6 +7853,9 @@ impl Render for ThreadView {
                 }
             }))
             .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
+                if this.thread.read(cx).status() != ThreadStatus::Idle {
+                    return;
+                }
                 if let Some(config_options_view) = this.config_options_view.clone() {
                     let handled = config_options_view.update(cx, |view, cx| {
                         view.toggle_category_picker(
@@ -7775,6 +7875,9 @@ impl Render for ThreadView {
                 }
             }))
             .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
+                if this.thread.read(cx).status() != ThreadStatus::Idle {
+                    return;
+                }
                 if let Some(config_options_view) = this.config_options_view.clone() {
                     let handled = config_options_view.update(cx, |view, cx| {
                         view.cycle_category_option(
@@ -7798,6 +7901,9 @@ impl Render for ThreadView {
             .children(self.render_subagent_titlebar(cx))
             .child(conversation)
             .children(self.render_activity_bar(window, cx))
+            .when(self.show_external_source_prompt_warning, |this| {
+                this.child(self.render_external_source_prompt_warning(cx))
+            })
             .when(self.show_codex_windows_warning, |this| {
                 this.child(self.render_codex_windows_warning(cx))
             })
@@ -7896,17 +8002,7 @@ pub(crate) fn open_link(
             MentionUri::Thread { id, name } => {
                 if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
                     panel.update(cx, |panel, cx| {
-                        panel.open_thread(
-                            AgentSessionInfo {
-                                session_id: id,
-                                cwd: None,
-                                title: Some(name.into()),
-                                updated_at: None,
-                                meta: None,
-                            },
-                            window,
-                            cx,
-                        )
+                        panel.open_thread(id, None, Some(name.into()), window, cx)
                     });
                 }
             }
@@ -7936,6 +8032,7 @@ pub(crate) fn open_link(
             MentionUri::Diagnostics { .. } => {}
             MentionUri::TerminalSelection { .. } => {}
             MentionUri::GitDiff { .. } => {}
+            MentionUri::MergeConflict { .. } => {}
         })
     } else {
         cx.open_url(&url);

crates/agent_ui/src/entry_view_state.rs 🔗

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

crates/agent_ui/src/external_source_prompt.rs 🔗

@@ -0,0 +1,162 @@
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ExternalSourcePrompt(String);
+
+impl ExternalSourcePrompt {
+    pub fn new(prompt: &str) -> Option<Self> {
+        sanitize(prompt).map(Self)
+    }
+
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+
+    pub fn into_string(self) -> String {
+        self.0
+    }
+}
+
+fn sanitize(prompt: &str) -> Option<String> {
+    let mut sanitized_prompt = String::with_capacity(prompt.len());
+    let mut consecutive_newline_count = 0;
+    let mut characters = prompt.chars().peekable();
+
+    while let Some(character) = characters.next() {
+        let character = if character == '\r' {
+            if characters.peek() == Some(&'\n') {
+                characters.next();
+            }
+            '\n'
+        } else {
+            character
+        };
+
+        if is_bidi_control_character(character) || is_disallowed_control_character(character) {
+            continue;
+        }
+
+        if character == '\n' {
+            consecutive_newline_count += 1;
+            if consecutive_newline_count > 2 {
+                continue;
+            }
+        } else {
+            consecutive_newline_count = 0;
+        }
+
+        sanitized_prompt.push(character);
+    }
+
+    if sanitized_prompt.is_empty() {
+        None
+    } else {
+        Some(sanitized_prompt)
+    }
+}
+
+fn is_disallowed_control_character(character: char) -> bool {
+    character.is_control() && !matches!(character, '\n' | '\t')
+}
+
+fn is_bidi_control_character(character: char) -> bool {
+    matches!(
+        character,
+          '\u{061C}' // ALM
+        | '\u{200E}' // LRM
+        | '\u{200F}' // RLM
+        | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
+        | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::ExternalSourcePrompt;
+
+    #[test]
+    fn keeps_normal_prompt_text() {
+        let prompt = ExternalSourcePrompt::new("Write me a script\nThanks");
+
+        assert_eq!(
+            prompt.as_ref().map(ExternalSourcePrompt::as_str),
+            Some("Write me a script\nThanks")
+        );
+    }
+
+    #[test]
+    fn keeps_multilingual_text() {
+        let prompt =
+            ExternalSourcePrompt::new("日本語の依頼です。\n中文提示也应该保留。\nemoji 👩‍💻");
+
+        assert_eq!(
+            prompt.as_ref().map(ExternalSourcePrompt::as_str),
+            Some("日本語の依頼です。\n中文提示也应该保留。\nemoji 👩‍💻")
+        );
+    }
+
+    #[test]
+    fn collapses_newline_padding() {
+        let prompt = ExternalSourcePrompt::new(
+            "Review this prompt carefully.\n\nThis paragraph should stay separated.\n\n\n\n\n\n\nWrite me a script to do fizz buzz.",
+        );
+
+        assert_eq!(
+            prompt.as_ref().map(ExternalSourcePrompt::as_str),
+            Some(
+                "Review this prompt carefully.\n\nThis paragraph should stay separated.\n\nWrite me a script to do fizz buzz."
+            )
+        );
+    }
+
+    #[test]
+    fn normalizes_carriage_returns() {
+        let prompt = ExternalSourcePrompt::new("Line one\r\nLine two\rLine three");
+
+        assert_eq!(
+            prompt.as_ref().map(ExternalSourcePrompt::as_str),
+            Some("Line one\nLine two\nLine three")
+        );
+    }
+
+    #[test]
+    fn strips_bidi_control_characters() {
+        let prompt = ExternalSourcePrompt::new("abc\u{202E}def\u{202C}ghi");
+
+        assert_eq!(
+            prompt.as_ref().map(ExternalSourcePrompt::as_str),
+            Some("abcdefghi")
+        );
+    }
+
+    #[test]
+    fn strips_other_control_characters() {
+        let prompt = ExternalSourcePrompt::new("safe\u{0000}\u{001B}\u{007F}text");
+
+        assert_eq!(
+            prompt.as_ref().map(ExternalSourcePrompt::as_str),
+            Some("safetext")
+        );
+    }
+
+    #[test]
+    fn keeps_tabs() {
+        let prompt = ExternalSourcePrompt::new("keep\tindentation");
+
+        assert_eq!(
+            prompt.as_ref().map(ExternalSourcePrompt::as_str),
+            Some("keep\tindentation")
+        );
+    }
+
+    #[test]
+    fn drops_empty_prompt() {
+        assert_eq!(ExternalSourcePrompt::new(""), None);
+    }
+
+    #[test]
+    fn drops_prompt_with_only_removed_characters() {
+        assert_eq!(
+            ExternalSourcePrompt::new("\u{202E}\u{202C}\u{0000}\u{001B}"),
+            None
+        );
+    }
+}

crates/agent_ui/src/inline_assistant.rs 🔗

@@ -266,7 +266,7 @@ impl InlineAssistant {
             return;
         };
 
-        let configuration_error = || {
+        let configuration_error = |cx| {
             let model_registry = LanguageModelRegistry::read_global(cx);
             model_registry.configuration_error(model_registry.inline_assistant_model(), cx)
         };
@@ -278,7 +278,15 @@ impl InlineAssistant {
 
         let prompt_store = agent_panel.prompt_store().as_ref().cloned();
         let thread_store = agent_panel.thread_store().clone();
-        let history = agent_panel.history().downgrade();
+        let Some(history) = agent_panel
+            .connection_store()
+            .read(cx)
+            .entry(&crate::Agent::NativeAgent)
+            .and_then(|s| s.read(cx).history().cloned())
+        else {
+            log::error!("No connection entry found for native agent");
+            return;
+        };
 
         let handle_assist =
             |window: &mut Window, cx: &mut Context<Workspace>| match inline_assist_target {
@@ -290,7 +298,7 @@ impl InlineAssistant {
                             workspace.project().downgrade(),
                             thread_store,
                             prompt_store,
-                            history,
+                            history.downgrade(),
                             action.prompt.clone(),
                             window,
                             cx,
@@ -305,7 +313,7 @@ impl InlineAssistant {
                             workspace.project().downgrade(),
                             thread_store,
                             prompt_store,
-                            history,
+                            history.downgrade(),
                             action.prompt.clone(),
                             window,
                             cx,
@@ -314,7 +322,7 @@ impl InlineAssistant {
                 }
             };
 
-        if let Some(error) = configuration_error() {
+        if let Some(error) = configuration_error(cx) {
             if let ConfigurationError::ProviderNotAuthenticated(provider) = error {
                 cx.spawn(async move |_, cx| {
                     cx.update(|cx| provider.authenticate(cx)).await?;
@@ -322,7 +330,7 @@ impl InlineAssistant {
                 })
                 .detach_and_log_err(cx);
 
-                if configuration_error().is_none() {
+                if configuration_error(cx).is_none() {
                     handle_assist(window, cx);
                 }
             } else {
@@ -1969,7 +1977,16 @@ impl CodeActionProvider for AssistantCodeActionProvider {
                     .panel::<AgentPanel>(cx)
                     .context("missing agent panel")?
                     .read(cx);
-                anyhow::Ok((panel.thread_store().clone(), panel.history().downgrade()))
+
+                let history = panel
+                    .connection_store()
+                    .read(cx)
+                    .entry(&crate::Agent::NativeAgent)
+                    .and_then(|e| e.read(cx).history())
+                    .context("no history found for native agent")?
+                    .downgrade();
+
+                anyhow::Ok((panel.thread_store().clone(), history))
             })??;
             let editor = editor.upgrade().context("editor was released")?;
             let range = editor
@@ -2120,7 +2137,7 @@ pub mod test {
             client::init(&client, cx);
             workspace::init(app_state.clone(), cx);
             let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-            language_model::init(client.clone(), cx);
+            language_model::init(user_store.clone(), client.clone(), cx);
             language_models::init(user_store, client.clone(), cx);
 
             cx.set_global(inline_assistant);
@@ -2155,7 +2172,7 @@ pub mod test {
             });
 
             let thread_store = cx.new(|cx| ThreadStore::new(cx));
-            let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx));
+            let history = cx.new(|cx| crate::ThreadHistory::new(None, cx));
 
             // Add editor to workspace
             workspace.update(cx, |workspace, cx| {

crates/agent_ui/src/inline_prompt_editor.rs 🔗

@@ -796,9 +796,11 @@ impl<T: 'static> PromptEditor<T> {
                 vec![
                     Button::new("start", mode.start_label())
                         .label_size(LabelSize::Small)
-                        .icon(IconName::Return)
-                        .icon_size(IconSize::XSmall)
-                        .icon_color(Color::Muted)
+                        .end_icon(
+                            Icon::new(IconName::Return)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                         .on_click(
                             cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::StartRequested)),
                         )

crates/agent_ui/src/mention_set.rs 🔗

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

crates/agent_ui/src/message_editor.rs 🔗

@@ -10,7 +10,7 @@ use crate::{
         Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
     },
 };
-use acp_thread::{AgentSessionInfo, MentionUri};
+use acp_thread::MentionUri;
 use agent::ThreadStore;
 use agent_client_protocol as acp;
 use anyhow::{Result, anyhow};
@@ -33,7 +33,7 @@ use rope::Point;
 use settings::Settings;
 use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc};
 use theme::ThemeSettings;
-use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*};
+use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*};
 use util::paths::PathStyle;
 use util::{ResultExt, debug_panic};
 use workspace::{CollaboratorId, Workspace};
@@ -80,6 +80,7 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
                 PromptContextType::Diagnostics,
                 PromptContextType::Fetch,
                 PromptContextType::Rules,
+                PromptContextType::BranchDiff,
             ]);
         }
         supported
@@ -301,7 +302,8 @@ impl MessageEditor {
 
     pub fn insert_thread_summary(
         &mut self,
-        thread: AgentSessionInfo,
+        session_id: acp::SessionId,
+        title: Option<SharedString>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -311,13 +313,11 @@ impl MessageEditor {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
-        let thread_title = thread
-            .title
-            .clone()
+        let thread_title = title
             .filter(|title| !title.is_empty())
             .unwrap_or_else(|| SharedString::new_static("New Thread"));
         let uri = MentionUri::Thread {
-            id: thread.session_id,
+            id: session_id,
             name: thread_title.to_string(),
         };
         let content = format!("{}\n", uri.as_link());
@@ -1041,6 +1041,88 @@ impl MessageEditor {
         });
     }
 
+    pub fn insert_branch_diff_crease(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let project = workspace.read(cx).project().clone();
+
+        let Some(repo) = project.read(cx).active_repository(cx) else {
+            return;
+        };
+
+        let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
+        let editor = self.editor.clone();
+        let mention_set = self.mention_set.clone();
+        let weak_workspace = self.workspace.clone();
+
+        window
+            .spawn(cx, async move |cx| {
+                let base_ref: SharedString = default_branch_receiver
+                    .await
+                    .ok()
+                    .and_then(|r| r.ok())
+                    .flatten()
+                    .ok_or_else(|| anyhow!("Could not determine default branch"))?;
+
+                cx.update(|window, cx| {
+                    let mention_uri = MentionUri::GitDiff {
+                        base_ref: base_ref.to_string(),
+                    };
+                    let mention_text = mention_uri.as_link().to_string();
+
+                    let (excerpt_id, text_anchor, content_len) = editor.update(cx, |editor, cx| {
+                        let buffer = editor.buffer().read(cx);
+                        let snapshot = buffer.snapshot(cx);
+                        let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
+                        let text_anchor = editor
+                            .selections
+                            .newest_anchor()
+                            .start
+                            .text_anchor
+                            .bias_left(&buffer_snapshot);
+
+                        editor.insert(&mention_text, window, cx);
+                        editor.insert(" ", window, cx);
+
+                        (excerpt_id, text_anchor, mention_text.len())
+                    });
+
+                    let Some((crease_id, tx)) = insert_crease_for_mention(
+                        excerpt_id,
+                        text_anchor,
+                        content_len,
+                        mention_uri.name().into(),
+                        mention_uri.icon_path(cx),
+                        mention_uri.tooltip_text(),
+                        Some(mention_uri.clone()),
+                        Some(weak_workspace),
+                        None,
+                        editor,
+                        window,
+                        cx,
+                    ) else {
+                        return;
+                    };
+                    drop(tx);
+
+                    let confirm_task = mention_set.update(cx, |mention_set, cx| {
+                        mention_set.confirm_mention_for_git_diff(base_ref, cx)
+                    });
+
+                    let mention_task = cx
+                        .spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string()))
+                        .shared();
+
+                    mention_set.update(cx, |mention_set, _| {
+                        mention_set.insert_mention(crease_id, mention_uri, mention_task);
+                    });
+                })
+            })
+            .detach_and_log_err(cx);
+    }
+
     fn insert_crease_impl(
         &mut self,
         text: String,
@@ -1079,11 +1161,9 @@ impl MessageEditor {
                 render: Arc::new({
                     let title = title.clone();
                     move |_fold_id, _fold_range, _cx| {
-                        ButtonLike::new("crease")
-                            .style(ButtonStyle::Filled)
+                        Button::new("crease", title.clone())
                             .layer(ElevationIndex::ElevatedSurface)
-                            .child(Icon::new(icon))
-                            .child(Label::new(title.clone()).single_line())
+                            .start_icon(Icon::new(icon))
                             .into_any_element()
                     }
                 }),
@@ -1223,8 +1303,10 @@ impl MessageEditor {
 
     pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
         self.editor.update(cx, |editor, cx| {
-            editor.set_mode(mode);
-            cx.notify()
+            if *editor.mode() != mode {
+                editor.set_mode(mode);
+                cx.notify()
+            }
         });
     }
 
@@ -1571,7 +1653,7 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
 mod tests {
     use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
 
-    use acp_thread::{AgentSessionInfo, MentionUri};
+    use acp_thread::MentionUri;
     use agent::{ThreadStore, outline};
     use agent_client_protocol as acp;
     use editor::{
@@ -1706,8 +1788,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -1820,8 +1901,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let workspace_handle = workspace.downgrade();
         let message_editor = workspace.update_in(cx, |_, window, cx| {
             cx.new(|cx| {
@@ -1976,8 +2056,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
         let available_commands = Rc::new(RefCell::new(vec![
             acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
@@ -2211,8 +2290,7 @@ mod tests {
         }
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
@@ -2707,8 +2785,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2808,17 +2885,10 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
-
-        // Create a thread metadata to insert as summary
-        let thread_metadata = AgentSessionInfo {
-            session_id: acp::SessionId::new("thread-123"),
-            cwd: None,
-            title: Some("Previous Conversation".into()),
-            updated_at: Some(chrono::Utc::now()),
-            meta: None,
-        };
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
+
+        let session_id = acp::SessionId::new("thread-123");
+        let title = Some("Previous Conversation".into());
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2839,17 +2909,17 @@ mod tests {
                     window,
                     cx,
                 );
-                editor.insert_thread_summary(thread_metadata.clone(), window, cx);
+                editor.insert_thread_summary(session_id.clone(), title.clone(), window, cx);
                 editor
             })
         });
 
         // Construct expected values for verification
         let expected_uri = MentionUri::Thread {
-            id: thread_metadata.session_id.clone(),
-            name: thread_metadata.title.as_ref().unwrap().to_string(),
+            id: session_id.clone(),
+            name: title.as_ref().unwrap().to_string(),
         };
-        let expected_title = thread_metadata.title.as_ref().unwrap();
+        let expected_title = title.as_ref().unwrap();
         let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
 
         message_editor.read_with(cx, |editor, cx| {
@@ -2890,16 +2960,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
-
-        let thread_metadata = AgentSessionInfo {
-            session_id: acp::SessionId::new("thread-123"),
-            cwd: None,
-            title: Some("Previous Conversation".into()),
-            updated_at: Some(chrono::Utc::now()),
-            meta: None,
-        };
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2920,7 +2981,12 @@ mod tests {
                     window,
                     cx,
                 );
-                editor.insert_thread_summary(thread_metadata, window, cx);
+                editor.insert_thread_summary(
+                    acp::SessionId::new("thread-123"),
+                    Some("Previous Conversation".into()),
+                    window,
+                    cx,
+                );
                 editor
             })
         });
@@ -2950,8 +3016,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3005,8 +3070,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3061,8 +3125,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3126,8 +3189,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3286,8 +3348,7 @@ mod tests {
         });
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
         // to ensure we have a fixed viewport, so we can eventually actually
@@ -3407,8 +3468,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3490,8 +3550,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3575,8 +3634,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3728,8 +3786,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {

crates/agent_ui/src/mode_selector.rs 🔗

@@ -169,10 +169,7 @@ impl Render for ModeSelector {
         let trigger_button = Button::new("mode-selector-trigger", current_mode_name)
             .label_size(LabelSize::Small)
             .color(Color::Muted)
-            .icon(icon)
-            .icon_size(IconSize::XSmall)
-            .icon_position(IconPosition::End)
-            .icon_color(Color::Muted)
+            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
             .disabled(self.setting_mode);
 
         PopoverMenu::new("mode-selector")

crates/agent_ui/src/model_selector_popover.rs 🔗

@@ -3,9 +3,9 @@ use std::sync::Arc;
 
 use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector};
 use fs::Fs;
-use gpui::{Entity, FocusHandle};
+use gpui::{AnyView, Entity, FocusHandle};
 use picker::popover_menu::PickerPopoverMenu;
-use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
+use ui::{PopoverMenuHandle, Tooltip, prelude::*};
 
 use crate::ui::ModelSelectorTooltip;
 use crate::{ModelSelector, model_selector::acp_model_selector};
@@ -13,6 +13,7 @@ use crate::{ModelSelector, model_selector::acp_model_selector};
 pub struct ModelSelectorPopover {
     selector: Entity<ModelSelector>,
     menu_handle: PopoverMenuHandle<ModelSelector>,
+    disabled: bool,
 }
 
 impl ModelSelectorPopover {
@@ -30,10 +31,18 @@ impl ModelSelectorPopover {
                 acp_model_selector(selector, agent_server, fs, focus_handle.clone(), window, cx)
             }),
             menu_handle,
+            disabled: false,
         }
     }
 
+    pub fn set_disabled(&mut self, disabled: bool) {
+        self.disabled = disabled;
+    }
+
     pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
+        if self.disabled {
+            return;
+        }
         self.menu_handle.toggle(window, cx);
     }
 
@@ -42,6 +51,9 @@ impl ModelSelectorPopover {
     }
 
     pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
+        if self.disabled {
+            return;
+        }
         self.selector.update(cx, |selector, cx| {
             selector.delegate.cycle_favorite_models(window, cx);
         });
@@ -61,26 +73,35 @@ impl Render for ModelSelectorPopover {
 
         let (color, icon) = if self.menu_handle.is_deployed() {
             (Color::Accent, IconName::ChevronUp)
+        } else if self.disabled {
+            (Color::Disabled, IconName::ChevronDown)
         } else {
             (Color::Muted, IconName::ChevronDown)
         };
 
         let show_cycle_row = selector.delegate.favorites_count() > 1;
+        let disabled = self.disabled;
 
-        let tooltip = Tooltip::element({
-            move |_, _cx| {
-                ModelSelectorTooltip::new()
-                    .show_cycle_row(show_cycle_row)
-                    .into_any_element()
-            }
-        });
+        let tooltip: Box<dyn Fn(&mut Window, &mut App) -> AnyView> = if disabled {
+            Box::new(Tooltip::text("Disabled until generation is done"))
+        } else {
+            Box::new(Tooltip::element({
+                move |_, _cx| {
+                    ModelSelectorTooltip::new()
+                        .show_cycle_row(show_cycle_row)
+                        .into_any_element()
+                }
+            }))
+        };
 
         PickerPopoverMenu::new(
             self.selector.clone(),
-            ButtonLike::new("active-model")
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+            Button::new("active-model", model_name)
+                .label_size(LabelSize::Small)
+                .color(color)
+                .disabled(self.disabled)
                 .when_some(model_icon, |this, icon| {
-                    this.child(
+                    this.start_icon(
                         match icon {
                             AgentModelIcon::Path(path) => Icon::from_external_svg(path),
                             AgentModelIcon::Named(icon_name) => Icon::new(icon_name),
@@ -89,13 +110,17 @@ impl Render for ModelSelectorPopover {
                         .size(IconSize::XSmall),
                     )
                 })
-                .child(
-                    Label::new(model_name)
-                        .color(color)
-                        .size(LabelSize::Small)
-                        .ml_0p5(),
-                )
-                .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)),
+                .end_icon(
+                    Icon::new(icon)
+                        .map(|this| {
+                            if self.disabled {
+                                this.color(Color::Disabled)
+                            } else {
+                                this.color(Color::Muted)
+                            }
+                        })
+                        .size(IconSize::XSmall),
+                ),
             tooltip,
             gpui::Corner::BottomRight,
             cx,

crates/agent_ui/src/profile_selector.rs 🔗

@@ -5,8 +5,8 @@ use agent_settings::{
 use fs::Fs;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
 use gpui::{
-    Action, AnyElement, App, BackgroundExecutor, Context, DismissEvent, Entity, FocusHandle,
-    Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window,
+    Action, AnyElement, AnyView, App, BackgroundExecutor, Context, DismissEvent, Entity,
+    FocusHandle, Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window,
 };
 use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu};
 use settings::{Settings as _, SettingsStore, update_settings_file};
@@ -16,7 +16,7 @@ use std::{
 };
 use ui::{
     DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem,
-    ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
+    ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
 };
 
 /// Trait for types that can provide and manage agent profiles
@@ -34,6 +34,7 @@ pub trait ProfileProvider {
 pub struct ProfileSelector {
     profiles: AvailableProfiles,
     pending_refresh: bool,
+    disabled: bool,
     fs: Arc<dyn Fs>,
     provider: Arc<dyn ProfileProvider>,
     picker: Option<Entity<Picker<ProfilePickerDelegate>>>,
@@ -57,6 +58,7 @@ impl ProfileSelector {
         Self {
             profiles: AgentProfile::available_profiles(cx),
             pending_refresh: false,
+            disabled: false,
             fs,
             provider,
             picker: None,
@@ -70,7 +72,19 @@ impl ProfileSelector {
         self.picker_handle.clone()
     }
 
+    pub fn set_disabled(&mut self, disabled: bool) {
+        self.disabled = disabled;
+    }
+
+    pub fn is_disabled(&self) -> bool {
+        self.disabled
+    }
+
     pub fn cycle_profile(&mut self, cx: &mut Context<Self>) {
+        if self.disabled {
+            return;
+        }
+
         if !self.provider.profiles_supported(cx) {
             return;
         }
@@ -175,18 +189,17 @@ impl Render for ProfileSelector {
         };
 
         let trigger_button = Button::new("profile-selector", selected_profile)
+            .disabled(self.disabled)
             .label_size(LabelSize::Small)
             .color(Color::Muted)
-            .icon(icon)
-            .icon_size(IconSize::XSmall)
-            .icon_position(IconPosition::End)
-            .icon_color(Color::Muted)
-            .selected_style(ButtonStyle::Tinted(TintColor::Accent));
+            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
 
-        PickerPopoverMenu::new(
-            picker,
-            trigger_button,
-            Tooltip::element({
+        let disabled = self.disabled;
+
+        let tooltip: Box<dyn Fn(&mut Window, &mut App) -> AnyView> = if disabled {
+            Box::new(Tooltip::text("Disabled until generation is done"))
+        } else {
+            Box::new(Tooltip::element({
                 move |_window, cx| {
                     let container = || h_flex().gap_1().justify_between();
                     v_flex()
@@ -206,7 +219,13 @@ impl Render for ProfileSelector {
                         )
                         .into_any()
                 }
-            }),
+            }))
+        };
+
+        PickerPopoverMenu::new(
+            picker,
+            trigger_button,
+            tooltip,
             gpui::Corner::BottomRight,
             cx,
         )

crates/agent_ui/src/sidebar.rs 🔗

@@ -0,0 +1,4926 @@
+use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
+use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread};
+use acp_thread::ThreadStatus;
+use action_log::DiffStats;
+use agent::ThreadStore;
+use agent_client_protocol as acp;
+use agent_settings::AgentSettings;
+use chrono::Utc;
+use db::kvp::KEY_VALUE_STORE;
+use editor::Editor;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
+use gpui::{
+    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels,
+    Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px,
+};
+use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use project::Event as ProjectEvent;
+use settings::Settings;
+use std::collections::{HashMap, HashSet};
+use std::mem;
+use std::path::Path;
+use std::sync::Arc;
+use theme::ActiveTheme;
+use ui::{
+    AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
+    Tooltip, WithScrollbar, prelude::*,
+};
+use util::ResultExt as _;
+use util::path_list::PathList;
+use workspace::{
+    MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
+};
+use zed_actions::editor::{MoveDown, MoveUp};
+
+actions!(
+    agents_sidebar,
+    [
+        /// Collapses the selected entry in the workspace sidebar.
+        CollapseSelectedEntry,
+        /// Expands the selected entry in the workspace sidebar.
+        ExpandSelectedEntry,
+    ]
+);
+
+const DEFAULT_WIDTH: Pixels = px(320.0);
+const MIN_WIDTH: Pixels = px(200.0);
+const MAX_WIDTH: Pixels = px(800.0);
+const DEFAULT_THREADS_SHOWN: usize = 5;
+const SIDEBAR_STATE_KEY: &str = "sidebar_state";
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+enum SidebarView {
+    #[default]
+    ThreadList,
+    Archive,
+}
+
+fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
+    KEY_VALUE_STORE
+        .scoped(SIDEBAR_STATE_KEY)
+        .read(&multi_workspace_id.to_string())
+        .log_err()
+        .flatten()
+        .and_then(|json| serde_json::from_str::<bool>(&json).ok())
+        .unwrap_or(false)
+}
+
+async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
+    if let Ok(json) = serde_json::to_string(&is_open) {
+        KEY_VALUE_STORE
+            .scoped(SIDEBAR_STATE_KEY)
+            .write(multi_workspace_id.to_string(), json)
+            .await
+            .log_err();
+    }
+}
+
+#[derive(Clone, Debug)]
+struct ActiveThreadInfo {
+    session_id: acp::SessionId,
+    title: SharedString,
+    status: AgentThreadStatus,
+    icon: IconName,
+    icon_from_external_svg: Option<SharedString>,
+    is_background: bool,
+    diff_stats: DiffStats,
+}
+
+impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
+    fn from(info: &ActiveThreadInfo) -> Self {
+        Self {
+            session_id: info.session_id.clone(),
+            cwd: None,
+            title: Some(info.title.clone()),
+            updated_at: Some(Utc::now()),
+            created_at: Some(Utc::now()),
+            meta: None,
+        }
+    }
+}
+
+#[derive(Clone)]
+enum ThreadEntryWorkspace {
+    Open(Entity<Workspace>),
+    Closed(PathList),
+}
+
+#[derive(Clone)]
+struct ThreadEntry {
+    agent: Agent,
+    session_info: acp_thread::AgentSessionInfo,
+    icon: IconName,
+    icon_from_external_svg: Option<SharedString>,
+    status: AgentThreadStatus,
+    workspace: ThreadEntryWorkspace,
+    is_live: bool,
+    is_background: bool,
+    highlight_positions: Vec<usize>,
+    worktree_name: Option<SharedString>,
+    worktree_highlight_positions: Vec<usize>,
+    diff_stats: DiffStats,
+}
+
+#[derive(Clone)]
+enum ListEntry {
+    ProjectHeader {
+        path_list: PathList,
+        label: SharedString,
+        workspace: Entity<Workspace>,
+        highlight_positions: Vec<usize>,
+        has_threads: bool,
+    },
+    Thread(ThreadEntry),
+    ViewMore {
+        path_list: PathList,
+        remaining_count: usize,
+        is_fully_expanded: bool,
+    },
+    NewThread {
+        path_list: PathList,
+        workspace: Entity<Workspace>,
+    },
+}
+
+impl From<ThreadEntry> for ListEntry {
+    fn from(thread: ThreadEntry) -> Self {
+        ListEntry::Thread(thread)
+    }
+}
+
+#[derive(Default)]
+struct SidebarContents {
+    entries: Vec<ListEntry>,
+    notified_threads: HashSet<acp::SessionId>,
+    project_header_indices: Vec<usize>,
+}
+
+impl SidebarContents {
+    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
+        self.notified_threads.contains(session_id)
+    }
+}
+
+fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
+    let mut positions = Vec::new();
+    let mut query_chars = query.chars().peekable();
+
+    for (byte_idx, candidate_char) in candidate.char_indices() {
+        if let Some(&query_char) = query_chars.peek() {
+            if candidate_char.eq_ignore_ascii_case(&query_char) {
+                positions.push(byte_idx);
+                query_chars.next();
+            }
+        } else {
+            break;
+        }
+    }
+
+    if query_chars.peek().is_none() {
+        Some(positions)
+    } else {
+        None
+    }
+}
+
+// TODO: The mapping from workspace root paths to git repositories needs a
+// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`,
+// thread persistence (which PathList is saved to the database), and thread
+// querying (which PathList is used to read threads back). All of these need
+// to agree on how repos are resolved for a given workspace, especially in
+// multi-root and nested-repo configurations.
+fn root_repository_snapshots(
+    workspace: &Entity<Workspace>,
+    cx: &App,
+) -> Vec<project::git_store::RepositorySnapshot> {
+    let path_list = workspace_path_list(workspace, cx);
+    let project = workspace.read(cx).project().read(cx);
+    project
+        .repositories(cx)
+        .values()
+        .filter_map(|repo| {
+            let snapshot = repo.read(cx).snapshot();
+            let is_root = path_list
+                .paths()
+                .iter()
+                .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
+            is_root.then_some(snapshot)
+        })
+        .collect()
+}
+
+fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
+    PathList::new(&workspace.read(cx).root_paths(cx))
+}
+
+fn workspace_label_from_path_list(path_list: &PathList) -> SharedString {
+    let mut names = Vec::with_capacity(path_list.paths().len());
+    for abs_path in path_list.paths() {
+        if let Some(name) = abs_path.file_name() {
+            names.push(name.to_string_lossy().to_string());
+        }
+    }
+    if names.is_empty() {
+        // TODO: Can we do something better in this case?
+        "Empty Workspace".into()
+    } else {
+        names.join(", ").into()
+    }
+}
+
+pub struct Sidebar {
+    multi_workspace: WeakEntity<MultiWorkspace>,
+    persistence_key: Option<u64>,
+    is_open: bool,
+    width: Pixels,
+    focus_handle: FocusHandle,
+    filter_editor: Entity<Editor>,
+    list_state: ListState,
+    contents: SidebarContents,
+    /// The index of the list item that currently has the keyboard focus
+    ///
+    /// Note: This is NOT the same as the active item.
+    selection: Option<usize>,
+    focused_thread: Option<acp::SessionId>,
+    active_entry_index: Option<usize>,
+    collapsed_groups: HashSet<PathList>,
+    expanded_groups: HashMap<PathList, usize>,
+    view: SidebarView,
+    archive_view: Option<Entity<ThreadsArchiveView>>,
+    _subscriptions: Vec<gpui::Subscription>,
+}
+
+impl Sidebar {
+    pub fn new(
+        multi_workspace: Entity<MultiWorkspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+        cx.on_focus_in(&focus_handle, window, Self::focus_in)
+            .detach();
+
+        let filter_editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_placeholder_text("Search…", window, cx);
+            editor
+        });
+
+        cx.subscribe_in(
+            &multi_workspace,
+            window,
+            |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
+                MultiWorkspaceEvent::ActiveWorkspaceChanged => {
+                    this.update_entries(cx);
+                }
+                MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
+                    this.subscribe_to_workspace(workspace, window, cx);
+                    this.update_entries(cx);
+                }
+                MultiWorkspaceEvent::WorkspaceRemoved(_) => {
+                    this.update_entries(cx);
+                }
+            },
+        )
+        .detach();
+
+        cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
+            if let editor::EditorEvent::BufferEdited = event {
+                let query = this.filter_editor.read(cx).text(cx);
+                if !query.is_empty() {
+                    this.selection.take();
+                }
+                this.update_entries(cx);
+                if !query.is_empty() {
+                    this.selection = this
+                        .contents
+                        .entries
+                        .iter()
+                        .position(|entry| matches!(entry, ListEntry::Thread(_)))
+                        .or_else(|| {
+                            if this.contents.entries.is_empty() {
+                                None
+                            } else {
+                                Some(0)
+                            }
+                        });
+                }
+            }
+        })
+        .detach();
+
+        let thread_store = ThreadStore::global(cx);
+        cx.observe_in(&thread_store, window, |this, _, _window, cx| {
+            this.update_entries(cx);
+        })
+        .detach();
+
+        cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
+            this.update_entries(cx);
+        })
+        .detach();
+
+        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
+        cx.defer_in(window, move |this, window, cx| {
+            for workspace in &workspaces {
+                this.subscribe_to_workspace(workspace, window, cx);
+            }
+            this.update_entries(cx);
+        });
+
+        let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
+        let is_open = persistence_key
+            .map(read_sidebar_open_state)
+            .unwrap_or(false);
+
+        Self {
+            multi_workspace: multi_workspace.downgrade(),
+            persistence_key,
+            is_open,
+            width: DEFAULT_WIDTH,
+            focus_handle,
+            filter_editor,
+            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
+            contents: SidebarContents::default(),
+            selection: None,
+            focused_thread: None,
+            active_entry_index: None,
+            collapsed_groups: HashSet::new(),
+            expanded_groups: HashMap::new(),
+            view: SidebarView::default(),
+            archive_view: None,
+            _subscriptions: Vec::new(),
+        }
+    }
+
+    fn subscribe_to_workspace(
+        &self,
+        workspace: &Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let project = workspace.read(cx).project().clone();
+        cx.subscribe_in(
+            &project,
+            window,
+            |this, _project, event, _window, cx| match event {
+                ProjectEvent::WorktreeAdded(_)
+                | ProjectEvent::WorktreeRemoved(_)
+                | ProjectEvent::WorktreeOrderChanged => {
+                    this.update_entries(cx);
+                }
+                _ => {}
+            },
+        )
+        .detach();
+
+        let git_store = workspace.read(cx).project().read(cx).git_store().clone();
+        cx.subscribe_in(
+            &git_store,
+            window,
+            |this, _, event: &project::git_store::GitStoreEvent, window, cx| {
+                if matches!(
+                    event,
+                    project::git_store::GitStoreEvent::RepositoryUpdated(
+                        _,
+                        project::git_store::RepositoryEvent::GitWorktreeListChanged,
+                        _,
+                    )
+                ) {
+                    this.prune_stale_worktree_workspaces(window, cx);
+                    this.update_entries(cx);
+                }
+            },
+        )
+        .detach();
+
+        cx.subscribe_in(
+            workspace,
+            window,
+            |this, _workspace, event: &workspace::Event, window, cx| {
+                if let workspace::Event::PanelAdded(view) = event {
+                    if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
+                        this.subscribe_to_agent_panel(&agent_panel, window, cx);
+                    }
+                }
+            },
+        )
+        .detach();
+
+        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+            self.subscribe_to_agent_panel(&agent_panel, window, cx);
+        }
+    }
+
+    fn subscribe_to_agent_panel(
+        &self,
+        agent_panel: &Entity<AgentPanel>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        cx.subscribe_in(
+            agent_panel,
+            window,
+            |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+                AgentPanelEvent::ActiveViewChanged
+                | AgentPanelEvent::ThreadFocused
+                | AgentPanelEvent::BackgroundThreadChanged => {
+                    this.update_entries(cx);
+                }
+            },
+        )
+        .detach();
+    }
+
+    fn all_thread_infos_for_workspace(
+        workspace: &Entity<Workspace>,
+        cx: &App,
+    ) -> Vec<ActiveThreadInfo> {
+        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
+            return Vec::new();
+        };
+        let agent_panel_ref = agent_panel.read(cx);
+
+        agent_panel_ref
+            .parent_threads(cx)
+            .into_iter()
+            .map(|thread_view| {
+                let thread_view_ref = thread_view.read(cx);
+                let thread = thread_view_ref.thread.read(cx);
+
+                let icon = thread_view_ref.agent_icon;
+                let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
+                let title = thread.title();
+                let session_id = thread.session_id().clone();
+                let is_background = agent_panel_ref.is_background_thread(&session_id);
+
+                let status = if thread.is_waiting_for_confirmation() {
+                    AgentThreadStatus::WaitingForConfirmation
+                } else if thread.had_error() {
+                    AgentThreadStatus::Error
+                } else {
+                    match thread.status() {
+                        ThreadStatus::Generating => AgentThreadStatus::Running,
+                        ThreadStatus::Idle => AgentThreadStatus::Completed,
+                    }
+                };
+
+                let diff_stats = thread.action_log().read(cx).diff_stats(cx);
+
+                ActiveThreadInfo {
+                    session_id,
+                    title,
+                    status,
+                    icon,
+                    icon_from_external_svg,
+                    is_background,
+                    diff_stats,
+                }
+            })
+            .collect()
+    }
+
+    fn rebuild_contents(&mut self, cx: &App) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+        let mw = multi_workspace.read(cx);
+        let workspaces = mw.workspaces().to_vec();
+        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
+
+        self.focused_thread = active_workspace
+            .as_ref()
+            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+            .and_then(|panel| panel.read(cx).active_connection_view().cloned())
+            .and_then(|cv| cv.read(cx).parent_id(cx));
+
+        let thread_store = ThreadStore::try_global(cx);
+        let query = self.filter_editor.read(cx).text(cx);
+
+        let previous = mem::take(&mut self.contents);
+
+        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
+            .entries
+            .iter()
+            .filter_map(|entry| match entry {
+                ListEntry::Thread(thread) if thread.is_live => {
+                    Some((thread.session_info.session_id.clone(), thread.status))
+                }
+                _ => None,
+            })
+            .collect();
+
+        let mut entries = Vec::new();
+        let mut notified_threads = previous.notified_threads;
+        // Track all session IDs we add to entries so we can prune stale
+        // notifications without a separate pass at the end.
+        let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
+        // Compute active_entry_index inline during the build pass.
+        let mut active_entry_index: Option<usize> = None;
+
+        // Identify absorbed workspaces in a single pass. A workspace is
+        // "absorbed" when it points at a git worktree checkout whose main
+        // repo is open as another workspace — its threads appear under the
+        // main repo's header instead of getting their own.
+        let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
+        let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
+        let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
+        let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
+
+        for (i, workspace) in workspaces.iter().enumerate() {
+            for snapshot in root_repository_snapshots(workspace, cx) {
+                if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path {
+                    main_repo_workspace
+                        .entry(snapshot.work_directory_abs_path.clone())
+                        .or_insert(i);
+                    if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
+                        for (ws_idx, name, ws_path) in waiting {
+                            absorbed.insert(ws_idx, (i, name));
+                            absorbed_workspace_by_path.insert(ws_path, ws_idx);
+                        }
+                    }
+                } else {
+                    let name: SharedString = snapshot
+                        .work_directory_abs_path
+                        .file_name()
+                        .unwrap_or_default()
+                        .to_string_lossy()
+                        .to_string()
+                        .into();
+                    if let Some(&main_idx) =
+                        main_repo_workspace.get(&snapshot.original_repo_abs_path)
+                    {
+                        absorbed.insert(i, (main_idx, name));
+                        absorbed_workspace_by_path
+                            .insert(snapshot.work_directory_abs_path.clone(), i);
+                    } else {
+                        pending
+                            .entry(snapshot.original_repo_abs_path.clone())
+                            .or_default()
+                            .push((i, name, snapshot.work_directory_abs_path.clone()));
+                    }
+                }
+            }
+        }
+
+        for (ws_index, workspace) in workspaces.iter().enumerate() {
+            if absorbed.contains_key(&ws_index) {
+                continue;
+            }
+
+            let path_list = workspace_path_list(workspace, cx);
+            let label = workspace_label_from_path_list(&path_list);
+
+            let is_collapsed = self.collapsed_groups.contains(&path_list);
+            let should_load_threads = !is_collapsed || !query.is_empty();
+
+            let mut threads: Vec<ThreadEntry> = Vec::new();
+
+            if should_load_threads {
+                let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
+
+                if let Some(ref thread_store) = thread_store {
+                    for meta in thread_store.read(cx).threads_for_paths(&path_list) {
+                        seen_session_ids.insert(meta.id.clone());
+                        threads.push(ThreadEntry {
+                            agent: Agent::NativeAgent,
+                            session_info: meta.into(),
+                            icon: IconName::ZedAgent,
+                            icon_from_external_svg: None,
+                            status: AgentThreadStatus::default(),
+                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
+                            is_live: false,
+                            is_background: false,
+                            highlight_positions: Vec::new(),
+                            worktree_name: None,
+                            worktree_highlight_positions: Vec::new(),
+                            diff_stats: DiffStats::default(),
+                        });
+                    }
+                }
+
+                // Load threads from linked git worktrees of this workspace's repos.
+                if let Some(ref thread_store) = thread_store {
+                    let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc<Path>)> =
+                        Vec::new();
+                    for snapshot in root_repository_snapshots(workspace, cx) {
+                        if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
+                            continue;
+                        }
+                        for git_worktree in snapshot.linked_worktrees() {
+                            let name = git_worktree
+                                .path
+                                .file_name()
+                                .unwrap_or_default()
+                                .to_string_lossy()
+                                .to_string();
+                            linked_worktree_queries.push((
+                                PathList::new(std::slice::from_ref(&git_worktree.path)),
+                                name.into(),
+                                Arc::from(git_worktree.path.as_path()),
+                            ));
+                        }
+                    }
+
+                    for (worktree_path_list, worktree_name, worktree_path) in
+                        &linked_worktree_queries
+                    {
+                        let target_workspace =
+                            match absorbed_workspace_by_path.get(worktree_path.as_ref()) {
+                                Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()),
+                                None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
+                            };
+
+                        for meta in thread_store.read(cx).threads_for_paths(worktree_path_list) {
+                            if !seen_session_ids.insert(meta.id.clone()) {
+                                continue;
+                            }
+                            threads.push(ThreadEntry {
+                                agent: Agent::NativeAgent,
+                                session_info: meta.into(),
+                                icon: IconName::ZedAgent,
+                                icon_from_external_svg: None,
+                                status: AgentThreadStatus::default(),
+                                workspace: target_workspace.clone(),
+                                is_live: false,
+                                is_background: false,
+                                highlight_positions: Vec::new(),
+                                worktree_name: Some(worktree_name.clone()),
+                                worktree_highlight_positions: Vec::new(),
+                                diff_stats: DiffStats::default(),
+                            });
+                        }
+                    }
+                }
+
+                let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
+
+                if !live_infos.is_empty() {
+                    let thread_index_by_session: HashMap<acp::SessionId, usize> = threads
+                        .iter()
+                        .enumerate()
+                        .map(|(i, t)| (t.session_info.session_id.clone(), i))
+                        .collect();
+
+                    for info in &live_infos {
+                        let Some(&idx) = thread_index_by_session.get(&info.session_id) else {
+                            continue;
+                        };
+
+                        let thread = &mut threads[idx];
+                        thread.session_info.title = Some(info.title.clone());
+                        thread.status = info.status;
+                        thread.icon = info.icon;
+                        thread.icon_from_external_svg = info.icon_from_external_svg.clone();
+                        thread.is_live = true;
+                        thread.is_background = info.is_background;
+                        thread.diff_stats = info.diff_stats;
+                    }
+                }
+
+                // Update notification state for live threads in the same pass.
+                let is_active_workspace = active_workspace
+                    .as_ref()
+                    .is_some_and(|active| active == workspace);
+
+                for thread in &threads {
+                    let session_id = &thread.session_info.session_id;
+                    if thread.is_background && thread.status == AgentThreadStatus::Completed {
+                        notified_threads.insert(session_id.clone());
+                    } else if thread.status == AgentThreadStatus::Completed
+                        && !is_active_workspace
+                        && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
+                    {
+                        notified_threads.insert(session_id.clone());
+                    }
+
+                    if is_active_workspace && !thread.is_background {
+                        notified_threads.remove(session_id);
+                    }
+                }
+
+                // Sort by created_at (newest first), falling back to updated_at
+                // for threads without a created_at (e.g., ACP sessions).
+                threads.sort_by(|a, b| {
+                    let a_time = a.session_info.created_at.or(a.session_info.updated_at);
+                    let b_time = b.session_info.created_at.or(b.session_info.updated_at);
+                    b_time.cmp(&a_time)
+                });
+            }
+
+            if !query.is_empty() {
+                let has_threads = !threads.is_empty();
+
+                let workspace_highlight_positions =
+                    fuzzy_match_positions(&query, &label).unwrap_or_default();
+                let workspace_matched = !workspace_highlight_positions.is_empty();
+
+                let mut matched_threads: Vec<ThreadEntry> = Vec::new();
+                for mut thread in threads {
+                    let title = thread
+                        .session_info
+                        .title
+                        .as_ref()
+                        .map(|s| s.as_ref())
+                        .unwrap_or("");
+                    if let Some(positions) = fuzzy_match_positions(&query, title) {
+                        thread.highlight_positions = positions;
+                    }
+                    if let Some(worktree_name) = &thread.worktree_name {
+                        if let Some(positions) = fuzzy_match_positions(&query, worktree_name) {
+                            thread.worktree_highlight_positions = positions;
+                        }
+                    }
+                    let worktree_matched = !thread.worktree_highlight_positions.is_empty();
+                    if workspace_matched
+                        || !thread.highlight_positions.is_empty()
+                        || worktree_matched
+                    {
+                        matched_threads.push(thread);
+                    }
+                }
+
+                if matched_threads.is_empty() && !workspace_matched {
+                    continue;
+                }
+
+                if active_entry_index.is_none()
+                    && self.focused_thread.is_none()
+                    && active_workspace
+                        .as_ref()
+                        .is_some_and(|active| active == workspace)
+                {
+                    active_entry_index = Some(entries.len());
+                }
+
+                entries.push(ListEntry::ProjectHeader {
+                    path_list: path_list.clone(),
+                    label,
+                    workspace: workspace.clone(),
+                    highlight_positions: workspace_highlight_positions,
+                    has_threads,
+                });
+
+                // Track session IDs and compute active_entry_index as we add
+                // thread entries.
+                for thread in matched_threads {
+                    current_session_ids.insert(thread.session_info.session_id.clone());
+                    if active_entry_index.is_none() {
+                        if let Some(focused) = &self.focused_thread {
+                            if &thread.session_info.session_id == focused {
+                                active_entry_index = Some(entries.len());
+                            }
+                        }
+                    }
+                    entries.push(thread.into());
+                }
+            } else {
+                let has_threads = !threads.is_empty();
+
+                // Check if this header is the active entry before pushing it.
+                if active_entry_index.is_none()
+                    && self.focused_thread.is_none()
+                    && active_workspace
+                        .as_ref()
+                        .is_some_and(|active| active == workspace)
+                {
+                    active_entry_index = Some(entries.len());
+                }
+
+                entries.push(ListEntry::ProjectHeader {
+                    path_list: path_list.clone(),
+                    label,
+                    workspace: workspace.clone(),
+                    highlight_positions: Vec::new(),
+                    has_threads,
+                });
+
+                if is_collapsed {
+                    continue;
+                }
+
+                let total = threads.len();
+
+                let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
+                let threads_to_show =
+                    DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
+                let count = threads_to_show.min(total);
+                let is_fully_expanded = count >= total;
+
+                // Track session IDs and compute active_entry_index as we add
+                // thread entries.
+                for thread in threads.into_iter().take(count) {
+                    current_session_ids.insert(thread.session_info.session_id.clone());
+                    if active_entry_index.is_none() {
+                        if let Some(focused) = &self.focused_thread {
+                            if &thread.session_info.session_id == focused {
+                                active_entry_index = Some(entries.len());
+                            }
+                        }
+                    }
+                    entries.push(thread.into());
+                }
+
+                if total > DEFAULT_THREADS_SHOWN {
+                    entries.push(ListEntry::ViewMore {
+                        path_list: path_list.clone(),
+                        remaining_count: total.saturating_sub(count),
+                        is_fully_expanded,
+                    });
+                }
+
+                if total == 0 {
+                    entries.push(ListEntry::NewThread {
+                        path_list: path_list.clone(),
+                        workspace: workspace.clone(),
+                    });
+                }
+            }
+        }
+
+        // Prune stale notifications using the session IDs we collected during
+        // the build pass (no extra scan needed).
+        notified_threads.retain(|id| current_session_ids.contains(id));
+
+        let project_header_indices = entries
+            .iter()
+            .enumerate()
+            .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i))
+            .collect();
+
+        self.active_entry_index = active_entry_index;
+        self.contents = SidebarContents {
+            entries,
+            notified_threads,
+            project_header_indices,
+        };
+    }
+
+    fn update_entries(&mut self, cx: &mut Context<Self>) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+        if !multi_workspace_enabled(cx) {
+            return;
+        }
+
+        let had_notifications = self.has_notifications(cx);
+
+        let scroll_position = self.list_state.logical_scroll_top();
+
+        self.rebuild_contents(cx);
+
+        self.list_state.reset(self.contents.entries.len());
+        self.list_state.scroll_to(scroll_position);
+
+        if had_notifications != self.has_notifications(cx) {
+            multi_workspace.update(cx, |_, cx| {
+                cx.notify();
+            });
+        }
+
+        cx.notify();
+    }
+
+    fn render_list_entry(
+        &mut self,
+        ix: usize,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let Some(entry) = self.contents.entries.get(ix) else {
+            return div().into_any_element();
+        };
+        let is_focused = self.focus_handle.is_focused(window)
+            || self.filter_editor.focus_handle(cx).is_focused(window);
+        // is_selected means the keyboard selector is here.
+        let is_selected = is_focused && self.selection == Some(ix);
+
+        let is_group_header_after_first =
+            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
+
+        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
+
+        let rendered = match entry {
+            ListEntry::ProjectHeader {
+                path_list,
+                label,
+                workspace,
+                highlight_positions,
+                has_threads,
+            } => self.render_project_header(
+                ix,
+                false,
+                path_list,
+                label,
+                workspace,
+                highlight_positions,
+                *has_threads,
+                is_selected,
+                docked_right,
+                cx,
+            ),
+            ListEntry::Thread(thread) => {
+                self.render_thread(ix, thread, is_selected, docked_right, cx)
+            }
+            ListEntry::ViewMore {
+                path_list,
+                remaining_count,
+                is_fully_expanded,
+            } => self.render_view_more(
+                ix,
+                path_list,
+                *remaining_count,
+                *is_fully_expanded,
+                is_selected,
+                cx,
+            ),
+            ListEntry::NewThread {
+                path_list,
+                workspace,
+            } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
+        };
+
+        if is_group_header_after_first {
+            v_flex()
+                .w_full()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(rendered)
+                .into_any_element()
+        } else {
+            rendered
+        }
+    }
+
+    fn render_project_header(
+        &self,
+        ix: usize,
+        is_sticky: bool,
+        path_list: &PathList,
+        label: &SharedString,
+        workspace: &Entity<Workspace>,
+        highlight_positions: &[usize],
+        has_threads: bool,
+        is_selected: bool,
+        docked_right: bool,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let id_prefix = if is_sticky { "sticky-" } else { "" };
+        let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
+        let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
+        let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}"));
+
+        let is_collapsed = self.collapsed_groups.contains(path_list);
+        let disclosure_icon = if is_collapsed {
+            IconName::ChevronRight
+        } else {
+            IconName::ChevronDown
+        };
+        let workspace_for_new_thread = workspace.clone();
+        let workspace_for_remove = workspace.clone();
+        // let workspace_for_activate = workspace.clone();
+
+        let path_list_for_toggle = path_list.clone();
+        let path_list_for_collapse = path_list.clone();
+        let view_more_expanded = self.expanded_groups.contains_key(path_list);
+
+        let multi_workspace = self.multi_workspace.upgrade();
+        let workspace_count = multi_workspace
+            .as_ref()
+            .map_or(0, |mw| mw.read(cx).workspaces().len());
+        let is_active_workspace = self.focused_thread.is_none()
+            && multi_workspace
+                .as_ref()
+                .is_some_and(|mw| mw.read(cx).workspace() == workspace);
+
+        let label = if highlight_positions.is_empty() {
+            Label::new(label.clone())
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+                .into_any_element()
+        } else {
+            HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+                .into_any_element()
+        };
+
+        ListItem::new(id)
+            .group_name(group_name)
+            .toggle_state(is_active_workspace)
+            .focused(is_selected)
+            .docked_right(docked_right)
+            .child(
+                h_flex()
+                    .relative()
+                    .min_w_0()
+                    .w_full()
+                    .py_1()
+                    .gap_1p5()
+                    .child(
+                        Icon::new(disclosure_icon)
+                            .size(IconSize::Small)
+                            .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
+                    )
+                    .child(label),
+            )
+            .end_hover_gradient_overlay(true)
+            .end_hover_slot(
+                h_flex()
+                    .when(workspace_count > 1, |this| {
+                        this.child(
+                            IconButton::new(
+                                SharedString::from(format!(
+                                    "{id_prefix}project-header-remove-{ix}",
+                                )),
+                                IconName::Close,
+                            )
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip(Tooltip::text("Remove Project"))
+                            .on_click(cx.listener(
+                                move |this, _, window, cx| {
+                                    this.remove_workspace(&workspace_for_remove, window, cx);
+                                },
+                            )),
+                        )
+                    })
+                    .when(view_more_expanded && !is_collapsed, |this| {
+                        this.child(
+                            IconButton::new(
+                                SharedString::from(format!(
+                                    "{id_prefix}project-header-collapse-{ix}",
+                                )),
+                                IconName::ListCollapse,
+                            )
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip(Tooltip::text("Collapse Displayed Threads"))
+                            .on_click(cx.listener({
+                                let path_list_for_collapse = path_list_for_collapse.clone();
+                                move |this, _, _window, cx| {
+                                    this.selection = None;
+                                    this.expanded_groups.remove(&path_list_for_collapse);
+                                    this.update_entries(cx);
+                                }
+                            })),
+                        )
+                    })
+                    .when(has_threads, |this| {
+                        this.child(
+                            IconButton::new(ib_id, IconName::NewThread)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted)
+                                .tooltip(Tooltip::text("New Thread"))
+                                .on_click(cx.listener(move |this, _, window, cx| {
+                                    this.selection = None;
+                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
+                                })),
+                        )
+                    }),
+            )
+            .on_click(cx.listener(move |this, _, window, cx| {
+                this.selection = None;
+                this.toggle_collapse(&path_list_for_toggle, window, cx);
+            }))
+            // TODO: Decide if we really want the header to be activating different workspaces
+            // .on_click(cx.listener(move |this, _, window, cx| {
+            //     this.selection = None;
+            //     this.activate_workspace(&workspace_for_activate, window, cx);
+            // }))
+            .into_any_element()
+    }
+
+    fn render_sticky_header(
+        &self,
+        docked_right: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        let scroll_top = self.list_state.logical_scroll_top();
+
+        let &header_idx = self
+            .contents
+            .project_header_indices
+            .iter()
+            .rev()
+            .find(|&&idx| idx <= scroll_top.item_ix)?;
+
+        let needs_sticky = header_idx < scroll_top.item_ix
+            || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.));
+
+        if !needs_sticky {
+            return None;
+        }
+
+        let ListEntry::ProjectHeader {
+            path_list,
+            label,
+            workspace,
+            highlight_positions,
+            has_threads,
+        } = self.contents.entries.get(header_idx)?
+        else {
+            return None;
+        };
+
+        let is_focused = self.focus_handle.is_focused(window)
+            || self.filter_editor.focus_handle(cx).is_focused(window);
+        let is_selected = is_focused && self.selection == Some(header_idx);
+
+        let header_element = self.render_project_header(
+            header_idx,
+            true,
+            &path_list,
+            &label,
+            &workspace,
+            &highlight_positions,
+            *has_threads,
+            is_selected,
+            docked_right,
+            cx,
+        );
+
+        let top_offset = self
+            .contents
+            .project_header_indices
+            .iter()
+            .find(|&&idx| idx > header_idx)
+            .and_then(|&next_idx| {
+                let bounds = self.list_state.bounds_for_item(next_idx)?;
+                let viewport = self.list_state.viewport_bounds();
+                let y_in_viewport = bounds.origin.y - viewport.origin.y;
+                let header_height = bounds.size.height;
+                (y_in_viewport < header_height).then_some(y_in_viewport - header_height)
+            })
+            .unwrap_or(px(0.));
+
+        let element = v_flex()
+            .absolute()
+            .top(top_offset)
+            .left_0()
+            .w_full()
+            .bg(cx.theme().colors().surface_background)
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .child(header_element)
+            .into_any_element();
+
+        Some(element)
+    }
+
+    fn activate_workspace(
+        &mut self,
+        workspace: &Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        multi_workspace.update(cx, |multi_workspace, cx| {
+            multi_workspace.activate(workspace.clone(), cx);
+        });
+
+        multi_workspace.update(cx, |multi_workspace, cx| {
+            multi_workspace.focus_active_workspace(window, cx);
+        });
+    }
+
+    fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
+
+        // Collect all worktree paths that are currently listed by any main
+        // repo open in any workspace.
+        let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
+        for workspace in &workspaces {
+            for snapshot in root_repository_snapshots(workspace, cx) {
+                if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
+                    continue;
+                }
+                for git_worktree in snapshot.linked_worktrees() {
+                    known_worktree_paths.insert(git_worktree.path.to_path_buf());
+                }
+            }
+        }
+
+        // Find workspaces that consist of exactly one root folder which is a
+        // stale worktree checkout. Multi-root workspaces are never pruned —
+        // losing one worktree shouldn't destroy a workspace that also
+        // contains other folders.
+        let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
+        for workspace in &workspaces {
+            let path_list = workspace_path_list(workspace, cx);
+            if path_list.paths().len() != 1 {
+                continue;
+            }
+            let should_prune = root_repository_snapshots(workspace, cx)
+                .iter()
+                .any(|snapshot| {
+                    snapshot.work_directory_abs_path != snapshot.original_repo_abs_path
+                        && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
+                });
+            if should_prune {
+                to_remove.push(workspace.clone());
+            }
+        }
+
+        for workspace in &to_remove {
+            self.remove_workspace(workspace, window, cx);
+        }
+    }
+
+    fn remove_workspace(
+        &mut self,
+        workspace: &Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        multi_workspace.update(cx, |multi_workspace, cx| {
+            let Some(index) = multi_workspace
+                .workspaces()
+                .iter()
+                .position(|w| w == workspace)
+            else {
+                return;
+            };
+            multi_workspace.remove_workspace(index, window, cx);
+        });
+    }
+
+    fn toggle_collapse(
+        &mut self,
+        path_list: &PathList,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.collapsed_groups.contains(path_list) {
+            self.collapsed_groups.remove(path_list);
+        } else {
+            self.collapsed_groups.insert(path_list.clone());
+        }
+        self.update_entries(cx);
+    }
+
+    fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
+
+    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
+        if self.reset_filter_editor_text(window, cx) {
+            self.update_entries(cx);
+        } else {
+            self.focus_handle.focus(window, cx);
+        }
+    }
+
+    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
+        self.filter_editor.update(cx, |editor, cx| {
+            if editor.buffer().read(cx).len(cx).0 > 0 {
+                editor.set_text("", window, cx);
+                true
+            } else {
+                false
+            }
+        })
+    }
+
+    fn has_filter_query(&self, cx: &App) -> bool {
+        !self.filter_editor.read(cx).text(cx).is_empty()
+    }
+
+    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_next(&SelectNext, window, cx);
+    }
+
+    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_previous(&SelectPrevious, window, cx);
+    }
+
+    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+        let next = match self.selection {
+            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
+            None if !self.contents.entries.is_empty() => 0,
+            _ => return,
+        };
+        self.selection = Some(next);
+        self.list_state.scroll_to_reveal_item(next);
+        cx.notify();
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let prev = match self.selection {
+            Some(ix) if ix > 0 => ix - 1,
+            None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1,
+            _ => return,
+        };
+        self.selection = Some(prev);
+        self.list_state.scroll_to_reveal_item(prev);
+        cx.notify();
+    }
+
+    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
+        if !self.contents.entries.is_empty() {
+            self.selection = Some(0);
+            self.list_state.scroll_to_reveal_item(0);
+            cx.notify();
+        }
+    }
+
+    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(last) = self.contents.entries.len().checked_sub(1) {
+            self.selection = Some(last);
+            self.list_state.scroll_to_reveal_item(last);
+            cx.notify();
+        }
+    }
+
+    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(ix) = self.selection else { return };
+        let Some(entry) = self.contents.entries.get(ix) else {
+            return;
+        };
+
+        match entry {
+            ListEntry::ProjectHeader { workspace, .. } => {
+                let workspace = workspace.clone();
+                self.activate_workspace(&workspace, window, cx);
+            }
+            ListEntry::Thread(thread) => {
+                let session_info = thread.session_info.clone();
+                match &thread.workspace {
+                    ThreadEntryWorkspace::Open(workspace) => {
+                        let workspace = workspace.clone();
+                        self.activate_thread(
+                            thread.agent.clone(),
+                            session_info,
+                            &workspace,
+                            window,
+                            cx,
+                        );
+                    }
+                    ThreadEntryWorkspace::Closed(path_list) => {
+                        self.open_workspace_and_activate_thread(
+                            thread.agent.clone(),
+                            session_info,
+                            path_list.clone(),
+                            window,
+                            cx,
+                        );
+                    }
+                }
+            }
+            ListEntry::ViewMore {
+                path_list,
+                is_fully_expanded,
+                ..
+            } => {
+                let path_list = path_list.clone();
+                if *is_fully_expanded {
+                    self.expanded_groups.remove(&path_list);
+                } else {
+                    let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
+                    self.expanded_groups.insert(path_list, current + 1);
+                }
+                self.update_entries(cx);
+            }
+            ListEntry::NewThread { workspace, .. } => {
+                let workspace = workspace.clone();
+                self.create_new_thread(&workspace, window, cx);
+            }
+        }
+    }
+
+    fn activate_thread(
+        &mut self,
+        agent: Agent,
+        session_info: acp_thread::AgentSessionInfo,
+        workspace: &Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        multi_workspace.update(cx, |multi_workspace, cx| {
+            multi_workspace.activate(workspace.clone(), cx);
+        });
+
+        workspace.update(cx, |workspace, cx| {
+            workspace.open_panel::<AgentPanel>(window, cx);
+        });
+
+        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+            agent_panel.update(cx, |panel, cx| {
+                panel.load_agent_thread(
+                    agent,
+                    session_info.session_id,
+                    session_info.cwd,
+                    session_info.title,
+                    true,
+                    window,
+                    cx,
+                );
+            });
+        }
+
+        self.update_entries(cx);
+    }
+
+    fn open_workspace_and_activate_thread(
+        &mut self,
+        agent: Agent,
+        session_info: acp_thread::AgentSessionInfo,
+        path_list: PathList,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        let paths: Vec<std::path::PathBuf> =
+            path_list.paths().iter().map(|p| p.to_path_buf()).collect();
+
+        let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx));
+
+        cx.spawn_in(window, async move |this, cx| {
+            let workspace = open_task.await?;
+            this.update_in(cx, |this, window, cx| {
+                this.activate_thread(agent, session_info, &workspace, window, cx);
+            })?;
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn find_open_workspace_for_path_list(
+        &self,
+        path_list: &PathList,
+        cx: &App,
+    ) -> Option<Entity<Workspace>> {
+        let multi_workspace = self.multi_workspace.upgrade()?;
+        multi_workspace
+            .read(cx)
+            .workspaces()
+            .iter()
+            .find(|workspace| workspace_path_list(workspace, cx).paths() == path_list.paths())
+            .cloned()
+    }
+
+    fn activate_archived_thread(
+        &mut self,
+        agent: Agent,
+        session_info: acp_thread::AgentSessionInfo,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| {
+            thread_store
+                .read(cx)
+                .thread_from_session_id(&session_info.session_id)
+                .map(|thread| thread.folder_paths.clone())
+        });
+        let path_list = saved_path_list.or_else(|| {
+            // we don't have saved metadata, so create path list based on the cwd
+            session_info
+                .cwd
+                .as_ref()
+                .map(|cwd| PathList::new(&[cwd.to_path_buf()]))
+        });
+
+        if let Some(path_list) = path_list {
+            if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) {
+                self.activate_thread(agent, session_info, &workspace, window, cx);
+            } else {
+                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
+            }
+            return;
+        }
+
+        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
+            w.read(cx)
+                .workspaces()
+                .get(w.read(cx).active_workspace_index())
+                .cloned()
+        });
+
+        if let Some(workspace) = active_workspace {
+            self.activate_thread(agent, session_info, &workspace, window, cx);
+        }
+    }
+
+    fn expand_selected_entry(
+        &mut self,
+        _: &ExpandSelectedEntry,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(ix) = self.selection else { return };
+
+        match self.contents.entries.get(ix) {
+            Some(ListEntry::ProjectHeader { path_list, .. }) => {
+                if self.collapsed_groups.contains(path_list) {
+                    let path_list = path_list.clone();
+                    self.collapsed_groups.remove(&path_list);
+                    self.update_entries(cx);
+                } else if ix + 1 < self.contents.entries.len() {
+                    self.selection = Some(ix + 1);
+                    self.list_state.scroll_to_reveal_item(ix + 1);
+                    cx.notify();
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn collapse_selected_entry(
+        &mut self,
+        _: &CollapseSelectedEntry,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(ix) = self.selection else { return };
+
+        match self.contents.entries.get(ix) {
+            Some(ListEntry::ProjectHeader { path_list, .. }) => {
+                if !self.collapsed_groups.contains(path_list) {
+                    let path_list = path_list.clone();
+                    self.collapsed_groups.insert(path_list);
+                    self.update_entries(cx);
+                }
+            }
+            Some(
+                ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+            ) => {
+                for i in (0..ix).rev() {
+                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
+                        self.contents.entries.get(i)
+                    {
+                        let path_list = path_list.clone();
+                        self.selection = Some(i);
+                        self.collapsed_groups.insert(path_list);
+                        self.update_entries(cx);
+                        break;
+                    }
+                }
+            }
+            None => {}
+        }
+    }
+
+    fn render_thread(
+        &self,
+        ix: usize,
+        thread: &ThreadEntry,
+        is_selected: bool,
+        docked_right: bool,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let has_notification = self
+            .contents
+            .is_thread_notified(&thread.session_info.session_id);
+
+        let title: SharedString = thread
+            .session_info
+            .title
+            .clone()
+            .unwrap_or_else(|| "Untitled".into());
+        let session_info = thread.session_info.clone();
+        let thread_workspace = thread.workspace.clone();
+
+        let id = SharedString::from(format!("thread-entry-{}", ix));
+
+        let timestamp = thread
+            .session_info
+            .created_at
+            .or(thread.session_info.updated_at)
+            .map(|entry_time| {
+                let now = Utc::now();
+                let duration = now.signed_duration_since(entry_time);
+
+                let minutes = duration.num_minutes();
+                let hours = duration.num_hours();
+                let days = duration.num_days();
+                let weeks = days / 7;
+                let months = days / 30;
+
+                if minutes < 60 {
+                    format!("{}m", minutes.max(1))
+                } else if hours < 24 {
+                    format!("{}h", hours)
+                } else if weeks < 4 {
+                    format!("{}w", weeks.max(1))
+                } else {
+                    format!("{}mo", months.max(1))
+                }
+            });
+
+        ThreadItem::new(id, title)
+            .icon(thread.icon)
+            .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
+                this.custom_icon_from_external_svg(svg)
+            })
+            .when_some(thread.worktree_name.clone(), |this, name| {
+                this.worktree(name)
+            })
+            .worktree_highlight_positions(thread.worktree_highlight_positions.clone())
+            .when_some(timestamp, |this, ts| this.timestamp(ts))
+            .highlight_positions(thread.highlight_positions.to_vec())
+            .status(thread.status)
+            .notified(has_notification)
+            .when(thread.diff_stats.lines_added > 0, |this| {
+                this.added(thread.diff_stats.lines_added as usize)
+            })
+            .when(thread.diff_stats.lines_removed > 0, |this| {
+                this.removed(thread.diff_stats.lines_removed as usize)
+            })
+            .selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
+            .focused(is_selected)
+            .docked_right(docked_right)
+            .on_click({
+                let agent = thread.agent.clone();
+                cx.listener(move |this, _, window, cx| {
+                    this.selection = None;
+                    match &thread_workspace {
+                        ThreadEntryWorkspace::Open(workspace) => {
+                            this.activate_thread(
+                                agent.clone(),
+                                session_info.clone(),
+                                workspace,
+                                window,
+                                cx,
+                            );
+                        }
+                        ThreadEntryWorkspace::Closed(path_list) => {
+                            this.open_workspace_and_activate_thread(
+                                agent.clone(),
+                                session_info.clone(),
+                                path_list.clone(),
+                                window,
+                                cx,
+                            );
+                        }
+                    }
+                })
+            })
+            .into_any_element()
+    }
+
+    fn render_filter_input(&self) -> impl IntoElement {
+        self.filter_editor.clone()
+    }
+
+    fn render_view_more(
+        &self,
+        ix: usize,
+        path_list: &PathList,
+        remaining_count: usize,
+        is_fully_expanded: bool,
+        is_selected: bool,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let path_list = path_list.clone();
+        let id = SharedString::from(format!("view-more-{}", ix));
+
+        let (icon, label) = if is_fully_expanded {
+            (IconName::ListCollapse, "Collapse")
+        } else {
+            (IconName::Plus, "View More")
+        };
+
+        ListItem::new(id)
+            .focused(is_selected)
+            .child(
+                h_flex()
+                    .py_1()
+                    .gap_1p5()
+                    .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
+                    .child(Label::new(label).color(Color::Muted))
+                    .when(!is_fully_expanded, |this| {
+                        this.child(
+                            Label::new(format!("({})", remaining_count))
+                                .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
+                        )
+                    }),
+            )
+            .on_click(cx.listener(move |this, _, _window, cx| {
+                this.selection = None;
+                if is_fully_expanded {
+                    this.expanded_groups.remove(&path_list);
+                } else {
+                    let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
+                    this.expanded_groups.insert(path_list.clone(), current + 1);
+                }
+                this.update_entries(cx);
+            }))
+            .into_any_element()
+    }
+
+    fn create_new_thread(
+        &mut self,
+        workspace: &Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        multi_workspace.update(cx, |multi_workspace, cx| {
+            multi_workspace.activate(workspace.clone(), cx);
+        });
+
+        workspace.update(cx, |workspace, cx| {
+            if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
+                agent_panel.update(cx, |panel, cx| {
+                    panel.new_thread(&NewThread, window, cx);
+                });
+            }
+            workspace.focus_panel::<AgentPanel>(window, cx);
+        });
+    }
+
+    fn render_new_thread(
+        &self,
+        ix: usize,
+        _path_list: &PathList,
+        workspace: &Entity<Workspace>,
+        is_selected: bool,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let workspace = workspace.clone();
+
+        div()
+            .w_full()
+            .p_2()
+            .pt_1p5()
+            .child(
+                Button::new(
+                    SharedString::from(format!("new-thread-btn-{}", ix)),
+                    "New Thread",
+                )
+                .full_width()
+                .style(ButtonStyle::Outlined)
+                .start_icon(
+                    Icon::new(IconName::Plus)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .toggle_state(is_selected)
+                .on_click(cx.listener(move |this, _, window, cx| {
+                    this.selection = None;
+                    this.create_new_thread(&workspace, window, cx);
+                })),
+            )
+            .into_any_element()
+    }
+
+    fn render_thread_list_header(
+        &self,
+        docked_right: bool,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let has_query = self.has_filter_query(cx);
+
+        h_flex()
+            .h(Tab::container_height(cx))
+            .flex_none()
+            .gap_1p5()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .when(!docked_right, |this| {
+                this.child(self.render_sidebar_toggle_button(false, cx))
+            })
+            .child(self.render_filter_input())
+            .when(has_query, |this| {
+                this.when(!docked_right, |this| this.pr_1p5()).child(
+                    IconButton::new("clear_filter", IconName::Close)
+                        .shape(IconButtonShape::Square)
+                        .tooltip(Tooltip::text("Clear Search"))
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.reset_filter_editor_text(window, cx);
+                            this.update_entries(cx);
+                        })),
+                )
+            })
+            .when(docked_right, |this| {
+                this.pl_2()
+                    .pr_0p5()
+                    .child(self.render_sidebar_toggle_button(true, cx))
+            })
+    }
+
+    fn render_thread_list_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        h_flex()
+            .p_1p5()
+            .border_t_1()
+            .border_color(cx.theme().colors().border_variant)
+            .child(
+                Button::new("view-archive", "Archive")
+                    .full_width()
+                    .label_size(LabelSize::Small)
+                    .style(ButtonStyle::Outlined)
+                    .start_icon(
+                        Icon::new(IconName::Archive)
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                    )
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        this.show_archive(window, cx);
+                    })),
+            )
+    }
+
+    fn render_sidebar_toggle_button(
+        &self,
+        docked_right: bool,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let icon = if docked_right {
+            IconName::ThreadsSidebarRightOpen
+        } else {
+            IconName::ThreadsSidebarLeftOpen
+        };
+
+        h_flex()
+            .h_full()
+            .px_1()
+            .map(|this| {
+                if docked_right {
+                    this.pr_1p5().border_l_1()
+                } else {
+                    this.border_r_1()
+                }
+            })
+            .border_color(cx.theme().colors().border_variant)
+            .child(
+                IconButton::new("sidebar-close-toggle", icon)
+                    .icon_size(IconSize::Small)
+                    .tooltip(move |_, cx| {
+                        Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
+                    })
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+                    }),
+            )
+    }
+}
+
+impl Sidebar {
+    pub fn is_open(&self) -> bool {
+        self.is_open
+    }
+
+    fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
+            w.read(cx)
+                .workspaces()
+                .get(w.read(cx).active_workspace_index())
+                .cloned()
+        }) else {
+            return;
+        };
+
+        let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
+            return;
+        };
+
+        let thread_store = agent_panel.read(cx).thread_store().clone();
+        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
+        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
+        let agent_server_store = active_workspace
+            .read(cx)
+            .project()
+            .read(cx)
+            .agent_server_store()
+            .clone();
+
+        let archive_view = cx.new(|cx| {
+            ThreadsArchiveView::new(
+                agent_connection_store,
+                agent_server_store,
+                thread_store,
+                fs,
+                window,
+                cx,
+            )
+        });
+        let subscription = cx.subscribe_in(
+            &archive_view,
+            window,
+            |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
+                ThreadsArchiveViewEvent::Close => {
+                    this.show_thread_list(window, cx);
+                }
+                ThreadsArchiveViewEvent::OpenThread {
+                    agent,
+                    session_info,
+                } => {
+                    this.show_thread_list(window, cx);
+                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
+                }
+            },
+        );
+
+        self._subscriptions.push(subscription);
+        self.archive_view = Some(archive_view);
+        self.view = SidebarView::Archive;
+        cx.notify();
+    }
+
+    fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.view = SidebarView::ThreadList;
+        self.archive_view = None;
+        self._subscriptions.clear();
+        window.focus(&self.focus_handle, cx);
+        cx.notify();
+    }
+
+    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
+        if self.is_open == open {
+            return;
+        }
+        self.is_open = open;
+        cx.notify();
+        if let Some(key) = self.persistence_key {
+            let is_open = self.is_open;
+            cx.background_spawn(async move {
+                save_sidebar_open_state(key, is_open).await;
+            })
+            .detach();
+        }
+    }
+
+    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let new_state = !self.is_open;
+        self.set_open(new_state, cx);
+        if new_state {
+            cx.focus_self(window);
+        }
+    }
+
+    pub fn focus_or_unfocus(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.is_open {
+            let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
+            if sidebar_is_focused {
+                let active_pane = workspace.active_pane().clone();
+                let pane_focus = active_pane.read(cx).focus_handle(cx);
+                window.focus(&pane_focus, cx);
+            } else {
+                cx.focus_self(window);
+            }
+        } else {
+            self.set_open(true, cx);
+            cx.focus_self(window);
+        }
+    }
+
+    pub fn width(&self, _cx: &App) -> Pixels {
+        self.width
+    }
+
+    pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
+        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
+        cx.notify();
+    }
+
+    pub fn has_notifications(&self, _cx: &App) -> bool {
+        !self.contents.notified_threads.is_empty()
+    }
+}
+
+impl Focusable for Sidebar {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.filter_editor.focus_handle(cx)
+    }
+}
+
+impl Render for Sidebar {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let ui_font = theme::setup_ui_font(window, cx);
+        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
+        let sticky_header = self.render_sticky_header(docked_right, window, cx);
+
+        v_flex()
+            .id("workspace-sidebar")
+            .key_context("WorkspaceSidebar")
+            .track_focus(&self.focus_handle)
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::editor_move_down))
+            .on_action(cx.listener(Self::editor_move_up))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::expand_selected_entry))
+            .on_action(cx.listener(Self::collapse_selected_entry))
+            .on_action(cx.listener(Self::cancel))
+            .font(ui_font)
+            .size_full()
+            .bg(cx.theme().colors().surface_background)
+            .map(|this| match self.view {
+                SidebarView::ThreadList => this
+                    .child(self.render_thread_list_header(docked_right, cx))
+                    .child(
+                        v_flex()
+                            .relative()
+                            .flex_1()
+                            .overflow_hidden()
+                            .child(
+                                list(
+                                    self.list_state.clone(),
+                                    cx.processor(Self::render_list_entry),
+                                )
+                                .flex_1()
+                                .size_full(),
+                            )
+                            .when_some(sticky_header, |this, header| this.child(header))
+                            .vertical_scrollbar_for(&self.list_state, window, cx),
+                    )
+                    .child(self.render_thread_list_footer(cx)),
+                SidebarView::Archive => {
+                    if let Some(archive_view) = &self.archive_view {
+                        this.child(archive_view.clone())
+                    } else {
+                        this
+                    }
+                }
+            })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
+    use acp_thread::StubAgentConnection;
+    use agent::ThreadStore;
+    use assistant_text_thread::TextThreadStore;
+    use chrono::DateTime;
+    use feature_flags::FeatureFlagAppExt as _;
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use std::sync::Arc;
+    use util::path_list::PathList;
+
+    fn init_test(cx: &mut TestAppContext) {
+        crate::test_support::init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec!["agent-v2".into()]);
+            ThreadStore::init_global(cx);
+            language_model::LanguageModelRegistry::test(cx);
+            prompt_store::init(cx);
+        });
+    }
+
+    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
+        agent::DbThread {
+            title: title.to_string().into(),
+            messages: Vec::new(),
+            updated_at,
+            detailed_summary: None,
+            initial_project_snapshot: None,
+            cumulative_token_usage: Default::default(),
+            request_token_usage: Default::default(),
+            model: None,
+            profile: None,
+            imported: false,
+            subagent_context: None,
+            speed: None,
+            thinking_enabled: false,
+            thinking_effort: None,
+            draft_prompt: None,
+            ui_scroll_position: None,
+        }
+    }
+
+    async fn init_test_project(
+        worktree_path: &str,
+        cx: &mut TestAppContext,
+    ) -> Entity<project::Project> {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+        project::Project::test(fs, [worktree_path.as_ref()], cx).await
+    }
+
+    fn setup_sidebar(
+        multi_workspace: &Entity<MultiWorkspace>,
+        cx: &mut gpui::VisualTestContext,
+    ) -> Entity<Sidebar> {
+        let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
+        sidebar
+    }
+
+    fn setup_sidebar_with_agent_panel(
+        multi_workspace: &Entity<MultiWorkspace>,
+        cx: &mut gpui::VisualTestContext,
+    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
+        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+        let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
+        let panel = add_agent_panel(&workspace, &project, cx);
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.right_dock().update(cx, |dock, cx| {
+                if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
+                    dock.activate_panel(panel_ix, window, cx);
+                }
+                dock.set_open(true, window, cx);
+            });
+        });
+        cx.run_until_parked();
+        let sidebar = panel.read_with(cx, |panel, _cx| {
+            panel
+                .sidebar
+                .clone()
+                .expect("AgentPanel should have created a sidebar")
+        });
+        (sidebar, panel)
+    }
+
+    async fn save_n_test_threads(
+        count: u32,
+        path_list: &PathList,
+        cx: &mut gpui::VisualTestContext,
+    ) {
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+        for i in 0..count {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
+                    make_test_thread(
+                        &format!("Thread {}", i + 1),
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
+                    ),
+                    path_list.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+        cx.run_until_parked();
+    }
+
+    async fn save_thread_to_store(
+        session_id: &acp::SessionId,
+        path_list: &PathList,
+        cx: &mut gpui::VisualTestContext,
+    ) {
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                session_id.clone(),
+                make_test_thread(
+                    "Test",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+    }
+
+    fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
+        cx.run_until_parked();
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.set_open(true, cx);
+            cx.focus_self(window);
+        });
+        cx.run_until_parked();
+    }
+
+    fn visible_entries_as_strings(
+        sidebar: &Entity<Sidebar>,
+        cx: &mut gpui::VisualTestContext,
+    ) -> Vec<String> {
+        sidebar.read_with(cx, |sidebar, _cx| {
+            sidebar
+                .contents
+                .entries
+                .iter()
+                .enumerate()
+                .map(|(ix, entry)| {
+                    let selected = if sidebar.selection == Some(ix) {
+                        "  <== selected"
+                    } else {
+                        ""
+                    };
+                    match entry {
+                        ListEntry::ProjectHeader {
+                            label,
+                            path_list,
+                            highlight_positions: _,
+                            ..
+                        } => {
+                            let icon = if sidebar.collapsed_groups.contains(path_list) {
+                                ">"
+                            } else {
+                                "v"
+                            };
+                            format!("{} [{}]{}", icon, label, selected)
+                        }
+                        ListEntry::Thread(thread) => {
+                            let title = thread
+                                .session_info
+                                .title
+                                .as_ref()
+                                .map(|s| s.as_ref())
+                                .unwrap_or("Untitled");
+                            let active = if thread.is_live { " *" } else { "" };
+                            let status_str = match thread.status {
+                                AgentThreadStatus::Running => " (running)",
+                                AgentThreadStatus::Error => " (error)",
+                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
+                                _ => "",
+                            };
+                            let notified = if sidebar
+                                .contents
+                                .is_thread_notified(&thread.session_info.session_id)
+                            {
+                                " (!)"
+                            } else {
+                                ""
+                            };
+                            let worktree = thread
+                                .worktree_name
+                                .as_ref()
+                                .map(|name| format!(" {{{}}}", name))
+                                .unwrap_or_default();
+                            format!(
+                                "  {}{}{}{}{}{}",
+                                title, worktree, active, status_str, notified, selected
+                            )
+                        }
+                        ListEntry::ViewMore {
+                            remaining_count,
+                            is_fully_expanded,
+                            ..
+                        } => {
+                            if *is_fully_expanded {
+                                format!("  - Collapse{}", selected)
+                            } else {
+                                format!("  + View More ({}){}", remaining_count, selected)
+                            }
+                        }
+                        ListEntry::NewThread { .. } => {
+                            format!("  [+ New Thread]{}", selected)
+                        }
+                    }
+                })
+                .collect()
+        })
+    }
+
+    #[gpui::test]
+    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  [+ New Thread]"]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("thread-1")),
+                make_test_thread(
+                    "Fix crash in project panel",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("thread-2")),
+                make_test_thread(
+                    "Add inline diff view",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Fix crash in project panel",
+                "  Add inline diff view",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
+        let project = init_test_project("/project-a", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Single workspace with a thread
+        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("thread-a1")),
+                make_test_thread(
+                    "Thread A1",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project-a]", "  Thread A1"]
+        );
+
+        // Add a second workspace
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.create_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Thread A1",
+                "v [Empty Workspace]",
+                "  [+ New Thread]"
+            ]
+        );
+
+        // Remove the second workspace
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.remove_workspace(1, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project-a]", "  Thread A1"]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_view_more_pagination(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(12, &path_list, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Thread 12",
+                "  Thread 11",
+                "  Thread 10",
+                "  Thread 9",
+                "  Thread 8",
+                "  + View More (7)",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
+        save_n_test_threads(17, &path_list, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Initially shows 5 threads + View More (12 remaining)
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 7); // header + 5 threads + View More
+        assert!(entries.iter().any(|e| e.contains("View More (12)")));
+
+        // Focus and navigate to View More, then confirm to expand by one batch
+        open_and_focus_sidebar(&sidebar, cx);
+        for _ in 0..7 {
+            cx.dispatch_action(SelectNext);
+        }
+        cx.dispatch_action(Confirm);
+        cx.run_until_parked();
+
+        // Now shows 10 threads + View More (7 remaining)
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 12); // header + 10 threads + View More
+        assert!(entries.iter().any(|e| e.contains("View More (7)")));
+
+        // Expand again by one batch
+        sidebar.update_in(cx, |s, _window, cx| {
+            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
+            s.expanded_groups.insert(path_list.clone(), current + 1);
+            s.update_entries(cx);
+        });
+        cx.run_until_parked();
+
+        // Now shows 15 threads + View More (2 remaining)
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 17); // header + 15 threads + View More
+        assert!(entries.iter().any(|e| e.contains("View More (2)")));
+
+        // Expand one more time - should show all 17 threads with Collapse button
+        sidebar.update_in(cx, |s, _window, cx| {
+            let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
+            s.expanded_groups.insert(path_list.clone(), current + 1);
+            s.update_entries(cx);
+        });
+        cx.run_until_parked();
+
+        // All 17 threads shown with Collapse button
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
+        assert!(!entries.iter().any(|e| e.contains("View More")));
+        assert!(entries.iter().any(|e| e.contains("Collapse")));
+
+        // Click collapse - should go back to showing 5 threads
+        sidebar.update_in(cx, |s, _window, cx| {
+            s.expanded_groups.remove(&path_list);
+            s.update_entries(cx);
+        });
+        cx.run_until_parked();
+
+        // Back to initial state: 5 threads + View More (12 remaining)
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 7); // header + 5 threads + View More
+        assert!(entries.iter().any(|e| e.contains("View More (12)")));
+    }
+
+    #[gpui::test]
+    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(1, &path_list, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Thread 1"]
+        );
+
+        // Collapse
+        sidebar.update_in(cx, |s, window, cx| {
+            s.toggle_collapse(&path_list, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["> [my-project]"]
+        );
+
+        // Expand
+        sidebar.update_in(cx, |s, window, cx| {
+            s.toggle_collapse(&path_list, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Thread 1"]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
+        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
+
+        sidebar.update_in(cx, |s, _window, _cx| {
+            s.collapsed_groups.insert(collapsed_path.clone());
+            s.contents
+                .notified_threads
+                .insert(acp::SessionId::new(Arc::from("t-5")));
+            s.contents.entries = vec![
+                // Expanded project header
+                ListEntry::ProjectHeader {
+                    path_list: expanded_path.clone(),
+                    label: "expanded-project".into(),
+                    workspace: workspace.clone(),
+                    highlight_positions: Vec::new(),
+                    has_threads: true,
+                },
+                // Thread with default (Completed) status, not active
+                ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
+                    session_info: acp_thread::AgentSessionInfo {
+                        session_id: acp::SessionId::new(Arc::from("t-1")),
+                        cwd: None,
+                        title: Some("Completed thread".into()),
+                        updated_at: Some(Utc::now()),
+                        created_at: Some(Utc::now()),
+                        meta: None,
+                    },
+                    icon: IconName::ZedAgent,
+                    icon_from_external_svg: None,
+                    status: AgentThreadStatus::Completed,
+                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
+                    is_live: false,
+                    is_background: false,
+                    highlight_positions: Vec::new(),
+                    worktree_name: None,
+                    worktree_highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
+                }),
+                // Active thread with Running status
+                ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
+                    session_info: acp_thread::AgentSessionInfo {
+                        session_id: acp::SessionId::new(Arc::from("t-2")),
+                        cwd: None,
+                        title: Some("Running thread".into()),
+                        updated_at: Some(Utc::now()),
+                        created_at: Some(Utc::now()),
+                        meta: None,
+                    },
+                    icon: IconName::ZedAgent,
+                    icon_from_external_svg: None,
+                    status: AgentThreadStatus::Running,
+                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
+                    is_live: true,
+                    is_background: false,
+                    highlight_positions: Vec::new(),
+                    worktree_name: None,
+                    worktree_highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
+                }),
+                // Active thread with Error status
+                ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
+                    session_info: acp_thread::AgentSessionInfo {
+                        session_id: acp::SessionId::new(Arc::from("t-3")),
+                        cwd: None,
+                        title: Some("Error thread".into()),
+                        updated_at: Some(Utc::now()),
+                        created_at: Some(Utc::now()),
+                        meta: None,
+                    },
+                    icon: IconName::ZedAgent,
+                    icon_from_external_svg: None,
+                    status: AgentThreadStatus::Error,
+                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
+                    is_live: true,
+                    is_background: false,
+                    highlight_positions: Vec::new(),
+                    worktree_name: None,
+                    worktree_highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
+                }),
+                // Thread with WaitingForConfirmation status, not active
+                ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
+                    session_info: acp_thread::AgentSessionInfo {
+                        session_id: acp::SessionId::new(Arc::from("t-4")),
+                        cwd: None,
+                        title: Some("Waiting thread".into()),
+                        updated_at: Some(Utc::now()),
+                        created_at: Some(Utc::now()),
+                        meta: None,
+                    },
+                    icon: IconName::ZedAgent,
+                    icon_from_external_svg: None,
+                    status: AgentThreadStatus::WaitingForConfirmation,
+                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
+                    is_live: false,
+                    is_background: false,
+                    highlight_positions: Vec::new(),
+                    worktree_name: None,
+                    worktree_highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
+                }),
+                // Background thread that completed (should show notification)
+                ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
+                    session_info: acp_thread::AgentSessionInfo {
+                        session_id: acp::SessionId::new(Arc::from("t-5")),
+                        cwd: None,
+                        title: Some("Notified thread".into()),
+                        updated_at: Some(Utc::now()),
+                        created_at: Some(Utc::now()),
+                        meta: None,
+                    },
+                    icon: IconName::ZedAgent,
+                    icon_from_external_svg: None,
+                    status: AgentThreadStatus::Completed,
+                    workspace: ThreadEntryWorkspace::Open(workspace.clone()),
+                    is_live: true,
+                    is_background: true,
+                    highlight_positions: Vec::new(),
+                    worktree_name: None,
+                    worktree_highlight_positions: Vec::new(),
+                    diff_stats: DiffStats::default(),
+                }),
+                // View More entry
+                ListEntry::ViewMore {
+                    path_list: expanded_path.clone(),
+                    remaining_count: 42,
+                    is_fully_expanded: false,
+                },
+                // Collapsed project header
+                ListEntry::ProjectHeader {
+                    path_list: collapsed_path.clone(),
+                    label: "collapsed-project".into(),
+                    workspace: workspace.clone(),
+                    highlight_positions: Vec::new(),
+                    has_threads: true,
+                },
+            ];
+            // Select the Running thread (index 2)
+            s.selection = Some(2);
+        });
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [expanded-project]",
+                "  Completed thread",
+                "  Running thread * (running)  <== selected",
+                "  Error thread * (error)",
+                "  Waiting thread (waiting)",
+                "  Notified thread * (!)",
+                "  + View More (42)",
+                "> [collapsed-project]",
+            ]
+        );
+
+        // Move selection to the collapsed header
+        sidebar.update_in(cx, |s, _window, _cx| {
+            s.selection = Some(7);
+        });
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx).last().cloned(),
+            Some("> [collapsed-project]  <== selected".to_string()),
+        );
+
+        // Clear selection
+        sidebar.update_in(cx, |s, _window, _cx| {
+            s.selection = None;
+        });
+
+        // No entry should have the selected marker
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        for entry in &entries {
+            assert!(
+                !entry.contains("<== selected"),
+                "unexpected selection marker in: {}",
+                entry
+            );
+        }
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(3, &path_list, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Entries: [header, thread3, thread2, thread1]
+        // Focusing the sidebar does not set a selection; select_next/select_previous
+        // handle None gracefully by starting from the first or last entry.
+        open_and_focus_sidebar(&sidebar, cx);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
+
+        // First SelectNext from None starts at index 0
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+
+        // Move down through remaining entries
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
+
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
+
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
+
+        // At the end, selection stays on the last entry
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
+
+        // Move back up
+
+        cx.dispatch_action(SelectPrevious);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
+
+        cx.dispatch_action(SelectPrevious);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
+
+        cx.dispatch_action(SelectPrevious);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+
+        // At the top, selection stays on the first entry
+        cx.dispatch_action(SelectPrevious);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(3, &path_list, cx).await;
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        open_and_focus_sidebar(&sidebar, cx);
+
+        // SelectLast jumps to the end
+        cx.dispatch_action(SelectLast);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
+
+        // SelectFirst jumps to the beginning
+        cx.dispatch_action(SelectFirst);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Initially no selection
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
+
+        // Open the sidebar so it's rendered, then focus it to trigger focus_in.
+        // focus_in no longer sets a default selection.
+        open_and_focus_sidebar(&sidebar, cx);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
+
+        // Manually set a selection, blur, then refocus — selection should be preserved
+        sidebar.update_in(cx, |sidebar, _window, _cx| {
+            sidebar.selection = Some(0);
+        });
+
+        cx.update(|window, _cx| {
+            window.blur();
+        });
+        cx.run_until_parked();
+
+        sidebar.update_in(cx, |_, window, cx| {
+            cx.focus_self(window);
+        });
+        cx.run_until_parked();
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.create_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        // Add an agent panel to workspace 1 so the sidebar renders when it's active.
+        setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(1, &path_list, cx).await;
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Thread 1",
+                "v [Empty Workspace]",
+                "  [+ New Thread]",
+            ]
+        );
+
+        // Switch to workspace 1 so we can verify confirm switches back.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(1, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1
+        );
+
+        // Focus the sidebar and manually select the header (index 0)
+        open_and_focus_sidebar(&sidebar, cx);
+        sidebar.update_in(cx, |sidebar, _window, _cx| {
+            sidebar.selection = Some(0);
+        });
+
+        // Press confirm on project header (workspace 0) to activate it.
+        cx.dispatch_action(Confirm);
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            0
+        );
+
+        // Focus should have moved out of the sidebar to the workspace center.
+        let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
+        workspace_0.update_in(cx, |workspace, window, cx| {
+            let pane_focus = workspace.active_pane().read(cx).focus_handle(cx);
+            assert!(
+                pane_focus.contains_focused(window, cx),
+                "Confirming a project header should focus the workspace center pane"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(8, &path_list, cx).await;
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Should show header + 5 threads + "View More (3)"
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 7);
+        assert!(entries.iter().any(|e| e.contains("View More (3)")));
+
+        // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
+        open_and_focus_sidebar(&sidebar, cx);
+        for _ in 0..7 {
+            cx.dispatch_action(SelectNext);
+        }
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
+
+        // Confirm on "View More" to expand
+        cx.dispatch_action(Confirm);
+        cx.run_until_parked();
+
+        // All 8 threads should now be visible with a "Collapse" button
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
+        assert!(!entries.iter().any(|e| e.contains("View More")));
+        assert!(entries.iter().any(|e| e.contains("Collapse")));
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(1, &path_list, cx).await;
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Thread 1"]
+        );
+
+        // Focus sidebar and manually select the header (index 0). Press left to collapse.
+        open_and_focus_sidebar(&sidebar, cx);
+        sidebar.update_in(cx, |sidebar, _window, _cx| {
+            sidebar.selection = Some(0);
+        });
+
+        cx.dispatch_action(CollapseSelectedEntry);
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["> [my-project]  <== selected"]
+        );
+
+        // Press right to expand
+        cx.dispatch_action(ExpandSelectedEntry);
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]  <== selected", "  Thread 1",]
+        );
+
+        // Press right again on already-expanded header moves selection down
+        cx.dispatch_action(ExpandSelectedEntry);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(1, &path_list, cx).await;
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Focus sidebar (selection starts at None), then navigate down to the thread (child)
+        open_and_focus_sidebar(&sidebar, cx);
+        cx.dispatch_action(SelectNext);
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Thread 1  <== selected",]
+        );
+
+        // Pressing left on a child collapses the parent group and selects it
+        cx.dispatch_action(CollapseSelectedEntry);
+        cx.run_until_parked();
+
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["> [my-project]  <== selected"]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
+        let project = init_test_project("/empty-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Even an empty project has the header and a new thread button
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [empty-project]", "  [+ New Thread]"]
+        );
+
+        // Focus sidebar — focus_in does not set a selection
+        open_and_focus_sidebar(&sidebar, cx);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
+
+        // First SelectNext from None starts at index 0 (header)
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+
+        // SelectNext moves to the new thread button
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
+
+        // At the end, selection stays on the last entry
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
+
+        // SelectPrevious goes back to the header
+        cx.dispatch_action(SelectPrevious);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
+    }
+
+    #[gpui::test]
+    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        save_n_test_threads(1, &path_list, cx).await;
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
+        open_and_focus_sidebar(&sidebar, cx);
+        cx.dispatch_action(SelectNext);
+        cx.dispatch_action(SelectNext);
+        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
+
+        // Collapse the group, which removes the thread from the list
+        cx.dispatch_action(CollapseSelectedEntry);
+        cx.run_until_parked();
+
+        // Selection should be clamped to the last valid index (0 = header)
+        let selection = sidebar.read_with(cx, |s, _| s.selection);
+        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
+        assert!(
+            selection.unwrap_or(0) < entry_count,
+            "selection {} should be within bounds (entries: {})",
+            selection.unwrap_or(0),
+            entry_count,
+        );
+    }
+
+    fn add_agent_panel(
+        workspace: &Entity<Workspace>,
+        project: &Entity<project::Project>,
+        cx: &mut gpui::VisualTestContext,
+    ) -> Entity<AgentPanel> {
+        workspace.update_in(cx, |workspace, window, cx| {
+            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
+            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
+            workspace.add_panel(panel.clone(), window, cx);
+            panel
+        })
+    }
+
+    #[gpui::test]
+    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+
+        // Open thread A and keep it generating.
+        let connection = StubAgentConnection::new();
+        open_thread_with_connection(&panel, connection.clone(), cx);
+        send_message(&panel, cx);
+
+        let session_id_a = active_session_id(&panel, cx);
+        save_thread_to_store(&session_id_a, &path_list, cx).await;
+
+        cx.update(|_, cx| {
+            connection.send_update(
+                session_id_a.clone(),
+                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        // Open thread B (idle, default response) — thread A goes to background.
+        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Done".into()),
+        )]);
+        open_thread_with_connection(&panel, connection, cx);
+        send_message(&panel, cx);
+
+        let session_id_b = active_session_id(&panel, cx);
+        save_thread_to_store(&session_id_b, &path_list, cx).await;
+
+        cx.run_until_parked();
+
+        let mut entries = visible_entries_as_strings(&sidebar, cx);
+        entries[1..].sort();
+        assert_eq!(
+            entries,
+            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
+        let project_a = init_test_project("/project-a", cx).await;
+        let (multi_workspace, cx) = cx
+            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
+
+        // Open thread on workspace A and keep it generating.
+        let connection_a = StubAgentConnection::new();
+        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
+        send_message(&panel_a, cx);
+
+        let session_id_a = active_session_id(&panel_a, cx);
+        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
+
+        cx.update(|_, cx| {
+            connection_a.send_update(
+                session_id_a.clone(),
+                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        // Add a second workspace and activate it (making workspace A the background).
+        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
+        let project_b = project::Project::test(fs, [], cx).await;
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+        cx.run_until_parked();
+
+        // Thread A is still running; no notification yet.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Hello * (running)",
+                "v [Empty Workspace]",
+                "  [+ New Thread]",
+            ]
+        );
+
+        // Complete thread A's turn (transition Running → Completed).
+        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
+        cx.run_until_parked();
+
+        // The completed background thread shows a notification indicator.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Hello * (!)",
+                "v [Empty Workspace]",
+                "  [+ New Thread]",
+            ]
+        );
+    }
+
+    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
+            sidebar.filter_editor.update(cx, |editor, cx| {
+                editor.set_text(query, window, cx);
+            });
+        });
+        cx.run_until_parked();
+    }
+
+    #[gpui::test]
+    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        for (id, title, hour) in [
+            ("t-1", "Fix crash in project panel", 3),
+            ("t-2", "Add inline diff view", 2),
+            ("t-3", "Refactor settings module", 1),
+        ] {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(id)),
+                    make_test_thread(
+                        title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+                    ),
+                    path_list.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Fix crash in project panel",
+                "  Add inline diff view",
+                "  Refactor settings module",
+            ]
+        );
+
+        // User types "diff" in the search box — only the matching thread remains,
+        // with its workspace header preserved for context.
+        type_in_search(&sidebar, "diff", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Add inline diff view  <== selected",]
+        );
+
+        // User changes query to something with no matches — list is empty.
+        type_in_search(&sidebar, "nonexistent", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            Vec::<String>::new()
+        );
+    }
+
+    #[gpui::test]
+    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
+        // Scenario: A user remembers a thread title but not the exact casing.
+        // Search should match case-insensitively so they can still find it.
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("thread-1")),
+                make_test_thread(
+                    "Fix Crash In Project Panel",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+
+        // Lowercase query matches mixed-case title.
+        type_in_search(&sidebar, "fix crash", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Fix Crash In Project Panel  <== selected",
+            ]
+        );
+
+        // Uppercase query also matches the same title.
+        type_in_search(&sidebar, "FIX CRASH", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Fix Crash In Project Panel  <== selected",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
+        // Scenario: A user searches, finds what they need, then presses Escape
+        // to dismiss the filter and see the full list again.
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(id)),
+                    make_test_thread(
+                        title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+                    ),
+                    path_list.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+        cx.run_until_parked();
+
+        // Confirm the full list is showing.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
+        );
+
+        // User types a search query to filter down.
+        open_and_focus_sidebar(&sidebar, cx);
+        type_in_search(&sidebar, "alpha", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Alpha thread  <== selected",]
+        );
+
+        // User presses Escape — filter clears, full list is restored.
+        cx.dispatch_action(Cancel);
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Alpha thread  <== selected",
+                "  Beta thread",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
+        let project_a = init_test_project("/project-a", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        for (id, title, hour) in [
+            ("a1", "Fix bug in sidebar", 2),
+            ("a2", "Add tests for editor", 1),
+        ] {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(id)),
+                    make_test_thread(
+                        title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+                    ),
+                    path_list_a.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+
+        // Add a second workspace.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.create_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+
+        for (id, title, hour) in [
+            ("b1", "Refactor sidebar layout", 3),
+            ("b2", "Fix typo in README", 1),
+        ] {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(id)),
+                    make_test_thread(
+                        title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+                    ),
+                    path_list_b.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Fix bug in sidebar",
+                "  Add tests for editor",
+                "v [Empty Workspace]",
+                "  Refactor sidebar layout",
+                "  Fix typo in README",
+            ]
+        );
+
+        // "sidebar" matches a thread in each workspace — both headers stay visible.
+        type_in_search(&sidebar, "sidebar", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Fix bug in sidebar  <== selected",
+                "v [Empty Workspace]",
+                "  Refactor sidebar layout",
+            ]
+        );
+
+        // "typo" only matches in the second workspace — the first header disappears.
+        type_in_search(&sidebar, "typo", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
+        );
+
+        // "project-a" matches the first workspace name — the header appears
+        // with all child threads included.
+        type_in_search(&sidebar, "project-a", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Fix bug in sidebar  <== selected",
+                "  Add tests for editor",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
+        let project_a = init_test_project("/alpha-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        for (id, title, hour) in [
+            ("a1", "Fix bug in sidebar", 2),
+            ("a2", "Add tests for editor", 1),
+        ] {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(id)),
+                    make_test_thread(
+                        title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+                    ),
+                    path_list_a.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+
+        // Add a second workspace.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.create_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+
+        for (id, title, hour) in [
+            ("b1", "Refactor sidebar layout", 3),
+            ("b2", "Fix typo in README", 1),
+        ] {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(id)),
+                    make_test_thread(
+                        title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+                    ),
+                    path_list_b.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+        cx.run_until_parked();
+
+        // "alpha" matches the workspace name "alpha-project" but no thread titles.
+        // The workspace header should appear with all child threads included.
+        type_in_search(&sidebar, "alpha", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [alpha-project]",
+                "  Fix bug in sidebar  <== selected",
+                "  Add tests for editor",
+            ]
+        );
+
+        // "sidebar" matches thread titles in both workspaces but not workspace names.
+        // Both headers appear with their matching threads.
+        type_in_search(&sidebar, "sidebar", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [alpha-project]",
+                "  Fix bug in sidebar  <== selected",
+                "v [Empty Workspace]",
+                "  Refactor sidebar layout",
+            ]
+        );
+
+        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
+        // doesn't match) — but does not match either workspace name or any thread.
+        // Actually let's test something simpler: a query that matches both a workspace
+        // name AND some threads in that workspace. Matching threads should still appear.
+        type_in_search(&sidebar, "fix", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [alpha-project]",
+                "  Fix bug in sidebar  <== selected",
+                "v [Empty Workspace]",
+                "  Fix typo in README",
+            ]
+        );
+
+        // A query that matches a workspace name AND a thread in that same workspace.
+        // Both the header (highlighted) and all child threads should appear.
+        type_in_search(&sidebar, "alpha", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [alpha-project]",
+                "  Fix bug in sidebar  <== selected",
+                "  Add tests for editor",
+            ]
+        );
+
+        // Now search for something that matches only a workspace name when there
+        // are also threads with matching titles — the non-matching workspace's
+        // threads should still appear if their titles match.
+        type_in_search(&sidebar, "alp", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [alpha-project]",
+                "  Fix bug in sidebar  <== selected",
+                "  Add tests for editor",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        // Create 8 threads. The oldest one has a unique name and will be
+        // behind View More (only 5 shown by default).
+        for i in 0..8u32 {
+            let title = if i == 0 {
+                "Hidden gem thread".to_string()
+            } else {
+                format!("Thread {}", i + 1)
+            };
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
+                    make_test_thread(
+                        &title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
+                    ),
+                    path_list.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+        cx.run_until_parked();
+
+        // Confirm the thread is not visible and View More is shown.
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert!(
+            entries.iter().any(|e| e.contains("View More")),
+            "should have View More button"
+        );
+        assert!(
+            !entries.iter().any(|e| e.contains("Hidden gem")),
+            "Hidden gem should be behind View More"
+        );
+
+        // User searches for the hidden thread — it appears, and View More is gone.
+        type_in_search(&sidebar, "hidden gem", cx);
+        let filtered = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(
+            filtered,
+            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
+        );
+        assert!(
+            !filtered.iter().any(|e| e.contains("View More")),
+            "View More should not appear when filtering"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("thread-1")),
+                make_test_thread(
+                    "Important thread",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+
+        // User focuses the sidebar and collapses the group using keyboard:
+        // manually select the header, then press CollapseSelectedEntry to collapse.
+        open_and_focus_sidebar(&sidebar, cx);
+        sidebar.update_in(cx, |sidebar, _window, _cx| {
+            sidebar.selection = Some(0);
+        });
+        cx.dispatch_action(CollapseSelectedEntry);
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["> [my-project]  <== selected"]
+        );
+
+        // User types a search — the thread appears even though its group is collapsed.
+        type_in_search(&sidebar, "important", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["> [my-project]", "  Important thread  <== selected",]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        for (id, title, hour) in [
+            ("t-1", "Fix crash in panel", 3),
+            ("t-2", "Fix lint warnings", 2),
+            ("t-3", "Add new feature", 1),
+        ] {
+            let save_task = thread_store.update(cx, |store, cx| {
+                store.save_thread(
+                    acp::SessionId::new(Arc::from(id)),
+                    make_test_thread(
+                        title,
+                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
+                    ),
+                    path_list.clone(),
+                    cx,
+                )
+            });
+            save_task.await.unwrap();
+        }
+        cx.run_until_parked();
+
+        open_and_focus_sidebar(&sidebar, cx);
+
+        // User types "fix" — two threads match.
+        type_in_search(&sidebar, "fix", cx);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Fix crash in panel  <== selected",
+                "  Fix lint warnings",
+            ]
+        );
+
+        // Selection starts on the first matching thread. User presses
+        // SelectNext to move to the second match.
+        cx.dispatch_action(SelectNext);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Fix crash in panel",
+                "  Fix lint warnings  <== selected",
+            ]
+        );
+
+        // User can also jump back with SelectPrevious.
+        cx.dispatch_action(SelectPrevious);
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Fix crash in panel  <== selected",
+                "  Fix lint warnings",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.create_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("hist-1")),
+                make_test_thread(
+                    "Historical Thread",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [my-project]",
+                "  Historical Thread",
+                "v [Empty Workspace]",
+                "  [+ New Thread]",
+            ]
+        );
+
+        // Switch to workspace 1 so we can verify the confirm switches back.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(1, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1
+        );
+
+        // Confirm on the historical (non-live) thread at index 1.
+        // Before a previous fix, the workspace field was Option<usize> and
+        // historical threads had None, so activate_thread early-returned
+        // without switching the workspace.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.selection = Some(1);
+            sidebar.confirm(&Confirm, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            0
+        );
+    }
+
+    #[gpui::test]
+    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("t-1")),
+                make_test_thread(
+                    "Thread A",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from("t-2")),
+                make_test_thread(
+                    "Thread B",
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Thread A", "  Thread B",]
+        );
+
+        // Keyboard confirm preserves selection.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.selection = Some(1);
+            sidebar.confirm(&Confirm, window, cx);
+        });
+        assert_eq!(
+            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
+            Some(1)
+        );
+
+        // Click handlers clear selection to None so no highlight lingers
+        // after a click regardless of focus state. The hover style provides
+        // visual feedback during mouse interaction instead.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.selection = None;
+            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+            sidebar.toggle_collapse(&path_list, window, cx);
+        });
+        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
+
+        // When the user tabs back into the sidebar, focus_in no longer
+        // restores selection — it stays None.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.focus_in(window, cx);
+        });
+        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
+    }
+
+    #[gpui::test]
+    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
+        let project = init_test_project("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+
+        let connection = StubAgentConnection::new();
+        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Hi there!".into()),
+        )]);
+        open_thread_with_connection(&panel, connection, cx);
+        send_message(&panel, cx);
+
+        let session_id = active_session_id(&panel, cx);
+        save_thread_to_store(&session_id, &path_list, cx).await;
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Hello *"]
+        );
+
+        // Simulate the agent generating a title. The notification chain is:
+        // AcpThread::set_title emits TitleUpdated →
+        // ConnectionView::handle_thread_event calls cx.notify() →
+        // AgentPanel observer fires and emits AgentPanelEvent →
+        // Sidebar subscription calls update_entries / rebuild_contents.
+        //
+        // Before the fix, handle_thread_event did NOT call cx.notify() for
+        // TitleUpdated, so the AgentPanel observer never fired and the
+        // sidebar kept showing the old title.
+        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
+        thread.update(cx, |thread, cx| {
+            thread
+                .set_title("Friendly Greeting with AI".into(), cx)
+                .detach();
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Friendly Greeting with AI *"]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
+        let project_a = init_test_project("/project-a", cx).await;
+        let (multi_workspace, cx) = cx
+            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
+
+        // Save a thread so it appears in the list.
+        let connection_a = StubAgentConnection::new();
+        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Done".into()),
+        )]);
+        open_thread_with_connection(&panel_a, connection_a, cx);
+        send_message(&panel_a, cx);
+        let session_id_a = active_session_id(&panel_a, cx);
+        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
+
+        // Add a second workspace with its own agent panel.
+        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
+        fs.as_fake()
+            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
+        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b.clone(), window, cx)
+        });
+        let panel_b = add_agent_panel(&workspace_b, &project_b, cx);
+        cx.run_until_parked();
+
+        let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
+
+        // ── 1. Initial state: no focused thread ──────────────────────────────
+        // Workspace B is active (just added) and has no thread, so its header
+        // is the active entry.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread, None,
+                "Initially no thread should be focused"
+            );
+            let active_entry = sidebar
+                .active_entry_index
+                .and_then(|ix| sidebar.contents.entries.get(ix));
+            assert!(
+                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
+                "Active entry should be the active workspace header"
+            );
+        });
+
+        // ── 2. Click thread in workspace A via sidebar ───────────────────────
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: session_id_a.clone(),
+                    cwd: None,
+                    title: Some("Test".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                &workspace_a,
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "After clicking a thread, it should be the focused thread"
+            );
+            let active_entry = sidebar.active_entry_index
+                .and_then(|ix| sidebar.contents.entries.get(ix));
+            assert!(
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
+                "Active entry should be the clicked thread"
+            );
+        });
+
+        workspace_a.read_with(cx, |workspace, cx| {
+            assert!(
+                workspace.panel::<AgentPanel>(cx).is_some(),
+                "Agent panel should exist"
+            );
+            let dock = workspace.right_dock().read(cx);
+            assert!(
+                dock.is_open(),
+                "Clicking a thread should open the agent panel dock"
+            );
+        });
+
+        // ── 3. Open thread in workspace B, then click it via sidebar ─────────
+        let connection_b = StubAgentConnection::new();
+        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Thread B".into()),
+        )]);
+        open_thread_with_connection(&panel_b, connection_b, cx);
+        send_message(&panel_b, cx);
+        let session_id_b = active_session_id(&panel_b, cx);
+        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
+        save_thread_to_store(&session_id_b, &path_list_b, cx).await;
+        cx.run_until_parked();
+
+        // Opening a thread in a non-active workspace should NOT change
+        // focused_thread — it's derived from the active workspace.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Opening a thread in a non-active workspace should not affect focused_thread"
+            );
+        });
+
+        // Workspace A is currently active. Click a thread in workspace B,
+        // which also triggers a workspace switch.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: session_id_b.clone(),
+                    cwd: None,
+                    title: Some("Thread B".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                &workspace_b,
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_b),
+                "Clicking a thread in another workspace should focus that thread"
+            );
+            let active_entry = sidebar
+                .active_entry_index
+                .and_then(|ix| sidebar.contents.entries.get(ix));
+            assert!(
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b),
+                "Active entry should be the cross-workspace thread"
+            );
+        });
+
+        // ── 4. Switch workspace → focused_thread reflects new workspace ──────
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_next_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        // Workspace A is now active. Its agent panel still has session_id_a
+        // loaded, so focused_thread should reflect that.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Switching workspaces should derive focused_thread from the new active workspace"
+            );
+            let active_entry = sidebar
+                .active_entry_index
+                .and_then(|ix| sidebar.contents.entries.get(ix));
+            assert!(
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
+                "Active entry should be workspace_a's active thread"
+            );
+        });
+
+        // ── 5. Opening a thread in a non-active workspace is ignored ──────────
+        let connection_b2 = StubAgentConnection::new();
+        connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("New thread".into()),
+        )]);
+        open_thread_with_connection(&panel_b, connection_b2, cx);
+        send_message(&panel_b, cx);
+        let session_id_b2 = active_session_id(&panel_b, cx);
+        save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
+        cx.run_until_parked();
+
+        // Workspace A is still active, so focused_thread stays on session_id_a.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Opening a thread in a non-active workspace should not affect focused_thread"
+            );
+        });
+
+        // ── 6. Activating workspace B shows its active thread ────────────────
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_workspace(&workspace_b, window, cx);
+        });
+        cx.run_until_parked();
+
+        // Workspace B is now active with session_id_b2 loaded.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_b2),
+                "Activating workspace_b should show workspace_b's active thread"
+            );
+            let active_entry = sidebar
+                .active_entry_index
+                .and_then(|ix| sidebar.contents.entries.get(ix));
+            assert!(
+                matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
+                "Active entry should be workspace_b's active thread"
+            );
+        });
+
+        // ── 7. Switching back to workspace A reflects its thread ─────────────
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_next_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread.as_ref(),
+                Some(&session_id_a),
+                "Switching back to workspace_a should show its active thread"
+            );
+        });
+    }
+
+    async fn save_named_thread(
+        session_id: &str,
+        title: &str,
+        path_list: &PathList,
+        cx: &mut gpui::VisualTestContext,
+    ) {
+        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
+        let save_task = thread_store.update(cx, |store, cx| {
+            store.save_thread(
+                acp::SessionId::new(Arc::from(session_id)),
+                make_test_thread(
+                    title,
+                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+                ),
+                path_list.clone(),
+                cx,
+            )
+        });
+        save_task.await.unwrap();
+        cx.run_until_parked();
+    }
+
+    async fn init_test_project_with_git(
+        worktree_path: &str,
+        cx: &mut TestAppContext,
+    ) -> (Entity<project::Project>, Arc<dyn fs::Fs>) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            worktree_path,
+            serde_json::json!({
+                ".git": {},
+                "src": {},
+            }),
+        )
+        .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+        let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await;
+        (project, fs)
+    }
+
+    #[gpui::test]
+    async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
+        let (project, fs) = init_test_project_with_git("/project", cx).await;
+
+        fs.as_fake()
+            .with_git_state(std::path::Path::new("/project/.git"), false, |state| {
+                state.worktrees.push(git::repository::Worktree {
+                    path: std::path::PathBuf::from("/wt/rosewood"),
+                    ref_name: "refs/heads/rosewood".into(),
+                    sha: "abc".into(),
+                });
+            })
+            .unwrap();
+
+        project
+            .update(cx, |project, cx| project.git_scans_complete(cx))
+            .await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
+        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
+        save_named_thread("main-t", "Unrelated Thread", &main_paths, cx).await;
+        save_named_thread("wt-t", "Fix Bug", &wt_paths, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Search for "rosewood" — should match the worktree name, not the title.
+        type_in_search(&sidebar, "rosewood", cx);
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project]", "  Fix Bug {rosewood}  <== selected"],
+        );
+    }
+
+    #[gpui::test]
+    async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
+        let (project, fs) = init_test_project_with_git("/project", cx).await;
+
+        project
+            .update(cx, |project, cx| project.git_scans_complete(cx))
+            .await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Save a thread against a worktree path that doesn't exist yet.
+        let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
+        save_named_thread("wt-thread", "Worktree Thread", &wt_paths, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Thread is not visible yet — no worktree knows about this path.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project]", "  [+ New Thread]"]
+        );
+
+        // Now add the worktree to the git state and trigger a rescan.
+        fs.as_fake()
+            .with_git_state(std::path::Path::new("/project/.git"), true, |state| {
+                state.worktrees.push(git::repository::Worktree {
+                    path: std::path::PathBuf::from("/wt/rosewood"),
+                    ref_name: "refs/heads/rosewood".into(),
+                    sha: "abc".into(),
+                });
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project]", "  Worktree Thread {rosewood}",]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        // Create the main repo directory (not opened as a workspace yet).
+        fs.insert_tree(
+            "/project",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "feature-a": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/feature-a",
+                        },
+                        "feature-b": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/feature-b",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+
+        // Two worktree checkouts whose .git files point back to the main repo.
+        fs.insert_tree(
+            "/wt-feature-a",
+            serde_json::json!({
+                ".git": "gitdir: /project/.git/worktrees/feature-a",
+                "src": {},
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/wt-feature-b",
+            serde_json::json!({
+                ".git": "gitdir: /project/.git/worktrees/feature-b",
+                "src": {},
+            }),
+        )
+        .await;
+
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
+        let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await;
+
+        project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await;
+        project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await;
+
+        // Open both worktrees as workspaces — no main repo yet.
+        let (multi_workspace, cx) = cx
+            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b.clone(), window, cx);
+        });
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+        let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
+        save_named_thread("thread-a", "Thread A", &paths_a, cx).await;
+        save_named_thread("thread-b", "Thread B", &paths_b, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Without the main repo, each worktree has its own header.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [wt-feature-a]",
+                "  Thread A",
+                "v [wt-feature-b]",
+                "  Thread B",
+            ]
+        );
+
+        // Configure the main repo to list both worktrees before opening
+        // it so the initial git scan picks them up.
+        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
+            state.worktrees.push(git::repository::Worktree {
+                path: std::path::PathBuf::from("/wt-feature-a"),
+                ref_name: "refs/heads/feature-a".into(),
+                sha: "aaa".into(),
+            });
+            state.worktrees.push(git::repository::Worktree {
+                path: std::path::PathBuf::from("/wt-feature-b"),
+                ref_name: "refs/heads/feature-b".into(),
+                sha: "bbb".into(),
+            });
+        })
+        .unwrap();
+
+        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+        main_project
+            .update(cx, |p, cx| p.git_scans_complete(cx))
+            .await;
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(main_project.clone(), window, cx);
+        });
+        cx.run_until_parked();
+
+        // Both worktree workspaces should now be absorbed under the main
+        // repo header, with worktree chips.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project]",
+                "  Thread A {wt-feature-a}",
+                "  Thread B {wt-feature-b}",
+            ]
+        );
+
+        // Remove feature-b from the main repo's linked worktrees.
+        // The feature-b workspace should be pruned automatically.
+        fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
+            state
+                .worktrees
+                .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b"));
+        })
+        .unwrap();
+
+        cx.run_until_parked();
+
+        // feature-b's workspace is pruned; feature-a remains absorbed
+        // under the main repo.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project]", "  Thread A {wt-feature-a}",]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        fs.insert_tree(
+            "/project",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "feature-a": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/feature-a",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+
+        fs.insert_tree(
+            "/wt-feature-a",
+            serde_json::json!({
+                ".git": "gitdir: /project/.git/worktrees/feature-a",
+                "src": {},
+            }),
+        )
+        .await;
+
+        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
+            state.worktrees.push(git::repository::Worktree {
+                path: std::path::PathBuf::from("/wt-feature-a"),
+                ref_name: "refs/heads/feature-a".into(),
+                sha: "aaa".into(),
+            });
+        })
+        .unwrap();
+
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        // Only open the main repo — no workspace for the worktree.
+        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+        main_project
+            .update(cx, |p, cx| p.git_scans_complete(cx))
+            .await;
+
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            MultiWorkspace::test_new(main_project.clone(), window, cx)
+        });
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Save a thread for the worktree path (no workspace for it).
+        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // Thread should appear under the main repo with a worktree chip.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project]", "  WT Thread {wt-feature-a}"],
+        );
+
+        // Only 1 workspace should exist.
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
+            1,
+        );
+
+        // Focus the sidebar and select the worktree thread.
+        open_and_focus_sidebar(&sidebar, cx);
+        sidebar.update_in(cx, |sidebar, _window, _cx| {
+            sidebar.selection = Some(1); // index 0 is header, 1 is the thread
+        });
+
+        // Confirm to open the worktree thread.
+        cx.dispatch_action(Confirm);
+        cx.run_until_parked();
+
+        // A new workspace should have been created for the worktree path.
+        let new_workspace = multi_workspace.read_with(cx, |mw, _| {
+            assert_eq!(
+                mw.workspaces().len(),
+                2,
+                "confirming a worktree thread without a workspace should open one",
+            );
+            mw.workspaces()[1].clone()
+        });
+
+        let new_path_list =
+            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
+        assert_eq!(
+            new_path_list,
+            PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
+            "the new workspace should have been opened for the worktree path",
+        );
+    }
+
+    #[gpui::test]
+    async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        fs.insert_tree(
+            "/project",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "feature-a": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/feature-a",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+
+        fs.insert_tree(
+            "/wt-feature-a",
+            serde_json::json!({
+                ".git": "gitdir: /project/.git/worktrees/feature-a",
+                "src": {},
+            }),
+        )
+        .await;
+
+        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
+            state.worktrees.push(git::repository::Worktree {
+                path: std::path::PathBuf::from("/wt-feature-a"),
+                ref_name: "refs/heads/feature-a".into(),
+                sha: "aaa".into(),
+            });
+        })
+        .unwrap();
+
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+        let worktree_project =
+            project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
+
+        main_project
+            .update(cx, |p, cx| p.git_scans_complete(cx))
+            .await;
+        worktree_project
+            .update(cx, |p, cx| p.git_scans_complete(cx))
+            .await;
+
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            MultiWorkspace::test_new(main_project.clone(), window, cx)
+        });
+
+        let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(worktree_project.clone(), window, cx)
+        });
+
+        // Activate the main workspace before setting up the sidebar.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(0, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]);
+        let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+        save_named_thread("thread-main", "Main Thread", &paths_main, cx).await;
+        save_named_thread("thread-wt", "WT Thread", &paths_wt, cx).await;
+
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+
+        // The worktree workspace should be absorbed under the main repo.
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert_eq!(entries.len(), 3);
+        assert_eq!(entries[0], "v [project]");
+        assert!(entries.contains(&"  Main Thread".to_string()));
+        assert!(entries.contains(&"  WT Thread {wt-feature-a}".to_string()));
+
+        let wt_thread_index = entries
+            .iter()
+            .position(|e| e.contains("WT Thread"))
+            .expect("should find the worktree thread entry");
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            0,
+            "main workspace should be active initially"
+        );
+
+        // Focus the sidebar and select the absorbed worktree thread.
+        open_and_focus_sidebar(&sidebar, cx);
+        sidebar.update_in(cx, |sidebar, _window, _cx| {
+            sidebar.selection = Some(wt_thread_index);
+        });
+
+        // Confirm to activate the worktree thread.
+        cx.dispatch_action(Confirm);
+        cx.run_until_parked();
+
+        // The worktree workspace should now be active, not the main one.
+        let active_workspace = multi_workspace.read_with(cx, |mw, _| {
+            mw.workspaces()[mw.active_workspace_index()].clone()
+        });
+        assert_eq!(
+            active_workspace, worktree_workspace,
+            "clicking an absorbed worktree thread should activate the worktree workspace"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has saved metadata in ThreadStore. A matching workspace is
+        // already open. Expected: activates the matching workspace.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Save a thread with path_list pointing to project-b.
+        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
+        let session_id = acp::SessionId::new(Arc::from("archived-1"));
+        save_thread_to_store(&session_id, &path_list_b, cx).await;
+
+        // Ensure workspace A is active.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(0, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            0
+        );
+
+        // Call activate_archived_thread – should resolve saved paths and
+        // switch to the workspace for project-b.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: session_id.clone(),
+                    cwd: Some("/project-b".into()),
+                    title: Some("Archived Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1,
+            "should have activated the workspace matching the saved path_list"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has no saved metadata but session_info has cwd. A matching
+        // workspace is open. Expected: uses cwd to find and activate it.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Start with workspace A active.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(0, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            0
+        );
+
+        // No thread saved to the store – cwd is the only path hint.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
+                    cwd: Some(std::path::PathBuf::from("/project-b")),
+                    title: Some("CWD Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1,
+            "should have activated the workspace matching the cwd"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has no saved metadata and no cwd. Expected: falls back to
+        // the currently active workspace.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Activate workspace B (index 1) to make it the active one.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(1, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1
+        );
+
+        // No saved thread, no cwd – should fall back to the active workspace.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
+                    cwd: None,
+                    title: Some("Contextless Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1,
+            "should have stayed on the active workspace when no path info is available"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has saved metadata pointing to a path with no open workspace.
+        // Expected: opens a new workspace for that path.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Save a thread with path_list pointing to project-b – which has no
+        // open workspace.
+        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
+        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
+        save_thread_to_store(&session_id, &path_list_b, cx).await;
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
+            1,
+            "should start with one workspace"
+        );
+
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: session_id.clone(),
+                    cwd: None,
+                    title: Some("New WS Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
+            2,
+            "should have opened a second workspace for the archived thread's saved paths"
+        );
+    }
+}

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -1191,11 +1191,11 @@ impl TextThreadEditor {
                                     Button::new("show-error", "Error")
                                         .color(Color::Error)
                                         .selected_label_color(Color::Error)
-                                        .selected_icon_color(Color::Error)
-                                        .icon(IconName::XCircle)
-                                        .icon_color(Color::Error)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(
+                                            Icon::new(IconName::XCircle)
+                                                .size(IconSize::XSmall)
+                                                .color(Color::Error),
+                                        )
                                         .tooltip(Tooltip::text("View Details"))
                                         .on_click({
                                             let text_thread = text_thread.clone();
@@ -2287,20 +2287,11 @@ impl TextThreadEditor {
 
         PickerPopoverMenu::new(
             self.language_model_selector.clone(),
-            ButtonLike::new("active-model")
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                .child(
-                    h_flex()
-                        .gap_0p5()
-                        .child(provider_icon_element)
-                        .child(
-                            Label::new(model_name)
-                                .color(color)
-                                .size(LabelSize::Small)
-                                .ml_0p5(),
-                        )
-                        .child(Icon::new(icon).color(color).size(IconSize::XSmall)),
-                ),
+            Button::new("active-model", model_name)
+                .color(color)
+                .label_size(LabelSize::Small)
+                .start_icon(provider_icon_element)
+                .end_icon(Icon::new(icon).color(color).size(IconSize::XSmall)),
             tooltip,
             gpui::Corner::BottomRight,
             cx,

crates/agent_ui/src/text_thread_history.rs 🔗

@@ -116,6 +116,10 @@ impl TextThreadHistory {
         this
     }
 
+    pub fn is_empty(&self) -> bool {
+        self.visible_items.is_empty()
+    }
+
     fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
         let entries = self.text_thread_store.update(cx, |store, _| {
             store.ordered_text_threads().cloned().collect::<Vec<_>>()

crates/agent_ui/src/thread_history.rs 🔗

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

crates/agent_ui/src/thread_history_view.rs 🔗

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

crates/agent_ui/src/threads_archive_view.rs 🔗

@@ -0,0 +1,691 @@
+use std::sync::Arc;
+
+use crate::{Agent, agent_connection_store::AgentConnectionStore, thread_history::ThreadHistory};
+use acp_thread::AgentSessionInfo;
+use agent::ThreadStore;
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
+use editor::Editor;
+use fs::Fs;
+use gpui::{
+    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render,
+    SharedString, Subscription, Task, Window, list, prelude::*, px,
+};
+use itertools::Itertools as _;
+use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use project::{AgentServerStore, ExternalAgentServerName};
+use theme::ActiveTheme;
+use ui::{
+    ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem,
+    PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
+};
+use util::ResultExt as _;
+use zed_actions::editor::{MoveDown, MoveUp};
+
+#[derive(Clone)]
+enum ArchiveListItem {
+    BucketSeparator(TimeBucket),
+    Entry {
+        session: AgentSessionInfo,
+        highlight_positions: Vec<usize>,
+    },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum TimeBucket {
+    Today,
+    Yesterday,
+    ThisWeek,
+    PastWeek,
+    Older,
+}
+
+impl TimeBucket {
+    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
+        if date == reference {
+            return TimeBucket::Today;
+        }
+        if date == reference - TimeDelta::days(1) {
+            return TimeBucket::Yesterday;
+        }
+        let week = date.iso_week();
+        if reference.iso_week() == week {
+            return TimeBucket::ThisWeek;
+        }
+        let last_week = (reference - TimeDelta::days(7)).iso_week();
+        if week == last_week {
+            return TimeBucket::PastWeek;
+        }
+        TimeBucket::Older
+    }
+
+    fn label(&self) -> &'static str {
+        match self {
+            TimeBucket::Today => "Today",
+            TimeBucket::Yesterday => "Yesterday",
+            TimeBucket::ThisWeek => "This Week",
+            TimeBucket::PastWeek => "Past Week",
+            TimeBucket::Older => "Older",
+        }
+    }
+}
+
+fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
+    let query = query.to_lowercase();
+    let text_lower = text.to_lowercase();
+    let mut positions = Vec::new();
+    let mut query_chars = query.chars().peekable();
+    for (i, c) in text_lower.chars().enumerate() {
+        if query_chars.peek() == Some(&c) {
+            positions.push(i);
+            query_chars.next();
+        }
+    }
+    if query_chars.peek().is_none() {
+        Some(positions)
+    } else {
+        None
+    }
+}
+
+pub enum ThreadsArchiveViewEvent {
+    Close,
+    OpenThread {
+        agent: Agent,
+        session_info: AgentSessionInfo,
+    },
+}
+
+impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
+
+pub struct ThreadsArchiveView {
+    agent_connection_store: Entity<AgentConnectionStore>,
+    agent_server_store: Entity<AgentServerStore>,
+    thread_store: Entity<ThreadStore>,
+    fs: Arc<dyn Fs>,
+    history: Option<Entity<ThreadHistory>>,
+    _history_subscription: Subscription,
+    selected_agent: Agent,
+    focus_handle: FocusHandle,
+    list_state: ListState,
+    items: Vec<ArchiveListItem>,
+    selection: Option<usize>,
+    filter_editor: Entity<Editor>,
+    _subscriptions: Vec<gpui::Subscription>,
+    selected_agent_menu: PopoverMenuHandle<ContextMenu>,
+    _refresh_history_task: Task<()>,
+    is_loading: bool,
+}
+
+impl ThreadsArchiveView {
+    pub fn new(
+        agent_connection_store: Entity<AgentConnectionStore>,
+        agent_server_store: Entity<AgentServerStore>,
+        thread_store: Entity<ThreadStore>,
+        fs: Arc<dyn Fs>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+
+        let filter_editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_placeholder_text("Search archive…", window, cx);
+            editor
+        });
+
+        let filter_editor_subscription =
+            cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
+                if let editor::EditorEvent::BufferEdited = event {
+                    this.update_items(cx);
+                }
+            });
+
+        let mut this = Self {
+            agent_connection_store,
+            agent_server_store,
+            thread_store,
+            fs,
+            history: None,
+            _history_subscription: Subscription::new(|| {}),
+            selected_agent: Agent::NativeAgent,
+            focus_handle,
+            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
+            items: Vec::new(),
+            selection: None,
+            filter_editor,
+            _subscriptions: vec![filter_editor_subscription],
+            selected_agent_menu: PopoverMenuHandle::default(),
+            _refresh_history_task: Task::ready(()),
+            is_loading: true,
+        };
+        this.set_selected_agent(Agent::NativeAgent, window, cx);
+        this
+    }
+
+    fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
+        self.selected_agent = agent.clone();
+        self.is_loading = true;
+        self.history = None;
+        self.items.clear();
+        self.selection = None;
+        self.list_state.reset(0);
+        self.reset_filter_editor_text(window, cx);
+
+        let server = agent.server(self.fs.clone(), self.thread_store.clone());
+        let connection = self
+            .agent_connection_store
+            .update(cx, |store, cx| store.request_connection(agent, server, cx));
+
+        let task = connection.read(cx).wait_for_connection();
+        self._refresh_history_task = cx.spawn(async move |this, cx| {
+            if let Some(state) = task.await.log_err() {
+                this.update(cx, |this, cx| this.set_history(state.history, cx))
+                    .ok();
+            }
+        });
+
+        cx.notify();
+    }
+
+    fn set_history(&mut self, history: Entity<ThreadHistory>, cx: &mut Context<Self>) {
+        self._history_subscription = cx.observe(&history, |this, _, cx| {
+            this.update_items(cx);
+        });
+        history.update(cx, |history, cx| {
+            history.refresh_full_history(cx);
+        });
+        self.history = Some(history);
+        self.is_loading = false;
+        self.update_items(cx);
+        cx.notify();
+    }
+
+    fn update_items(&mut self, cx: &mut Context<Self>) {
+        let Some(history) = self.history.as_ref() else {
+            return;
+        };
+
+        let sessions = history.read(cx).sessions().to_vec();
+        let query = self.filter_editor.read(cx).text(cx).to_lowercase();
+        let today = Local::now().naive_local().date();
+
+        let mut items = Vec::with_capacity(sessions.len() + 5);
+        let mut current_bucket: Option<TimeBucket> = None;
+
+        for session in sessions {
+            let highlight_positions = if !query.is_empty() {
+                let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or("");
+                match fuzzy_match_positions(&query, title) {
+                    Some(positions) => positions,
+                    None => continue,
+                }
+            } else {
+                Vec::new()
+            };
+
+            let entry_bucket = session
+                .updated_at
+                .map(|timestamp| {
+                    let entry_date = timestamp.with_timezone(&Local).naive_local().date();
+                    TimeBucket::from_dates(today, entry_date)
+                })
+                .unwrap_or(TimeBucket::Older);
+
+            if Some(entry_bucket) != current_bucket {
+                current_bucket = Some(entry_bucket);
+                items.push(ArchiveListItem::BucketSeparator(entry_bucket));
+            }
+
+            items.push(ArchiveListItem::Entry {
+                session,
+                highlight_positions,
+            });
+        }
+
+        self.list_state.reset(items.len());
+        self.items = items;
+        cx.notify();
+    }
+
+    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.filter_editor.update(cx, |editor, cx| {
+            editor.set_text("", window, cx);
+        });
+    }
+
+    fn go_back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.reset_filter_editor_text(window, cx);
+        cx.emit(ThreadsArchiveViewEvent::Close);
+    }
+
+    fn open_thread(
+        &mut self,
+        session_info: AgentSessionInfo,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.selection = None;
+        self.reset_filter_editor_text(window, cx);
+        cx.emit(ThreadsArchiveViewEvent::OpenThread {
+            agent: self.selected_agent.clone(),
+            session_info,
+        });
+    }
+
+    fn is_selectable_item(&self, ix: usize) -> bool {
+        matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
+    }
+
+    fn find_next_selectable(&self, start: usize) -> Option<usize> {
+        (start..self.items.len()).find(|&i| self.is_selectable_item(i))
+    }
+
+    fn find_previous_selectable(&self, start: usize) -> Option<usize> {
+        (0..=start).rev().find(|&i| self.is_selectable_item(i))
+    }
+
+    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_next(&SelectNext, window, cx);
+    }
+
+    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_previous(&SelectPrevious, window, cx);
+    }
+
+    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+        let next = match self.selection {
+            Some(ix) => self.find_next_selectable(ix + 1),
+            None => self.find_next_selectable(0),
+        };
+        if let Some(next) = next {
+            self.selection = Some(next);
+            self.list_state.scroll_to_reveal_item(next);
+            cx.notify();
+        }
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let prev = match self.selection {
+            Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1),
+            None => {
+                let last = self.items.len().saturating_sub(1);
+                self.find_previous_selectable(last)
+            }
+            _ => return,
+        };
+        if let Some(prev) = prev {
+            self.selection = Some(prev);
+            self.list_state.scroll_to_reveal_item(prev);
+            cx.notify();
+        }
+    }
+
+    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(first) = self.find_next_selectable(0) {
+            self.selection = Some(first);
+            self.list_state.scroll_to_reveal_item(first);
+            cx.notify();
+        }
+    }
+
+    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        let last = self.items.len().saturating_sub(1);
+        if let Some(last) = self.find_previous_selectable(last) {
+            self.selection = Some(last);
+            self.list_state.scroll_to_reveal_item(last);
+            cx.notify();
+        }
+    }
+
+    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(ix) = self.selection else { return };
+        let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
+            return;
+        };
+        self.open_thread(session.clone(), window, cx);
+    }
+
+    fn render_list_entry(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let Some(item) = self.items.get(ix) else {
+            return div().into_any_element();
+        };
+
+        match item {
+            ArchiveListItem::BucketSeparator(bucket) => div()
+                .w_full()
+                .px_2()
+                .pt_3()
+                .pb_1()
+                .child(
+                    Label::new(bucket.label())
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                )
+                .into_any_element(),
+            ArchiveListItem::Entry {
+                session,
+                highlight_positions,
+            } => {
+                let is_selected = self.selection == Some(ix);
+                let title: SharedString =
+                    session.title.clone().unwrap_or_else(|| "Untitled".into());
+                let session_info = session.clone();
+                let highlight_positions = highlight_positions.clone();
+
+                let timestamp = session.created_at.or(session.updated_at).map(|entry_time| {
+                    let now = Utc::now();
+                    let duration = now.signed_duration_since(entry_time);
+
+                    let minutes = duration.num_minutes();
+                    let hours = duration.num_hours();
+                    let days = duration.num_days();
+                    let weeks = days / 7;
+                    let months = days / 30;
+
+                    if minutes < 60 {
+                        format!("{}m", minutes.max(1))
+                    } else if hours < 24 {
+                        format!("{}h", hours)
+                    } else if weeks < 4 {
+                        format!("{}w", weeks.max(1))
+                    } else {
+                        format!("{}mo", months.max(1))
+                    }
+                });
+
+                let id = SharedString::from(format!("archive-entry-{}", ix));
+
+                let title_label = if highlight_positions.is_empty() {
+                    Label::new(title)
+                        .size(LabelSize::Small)
+                        .truncate()
+                        .into_any_element()
+                } else {
+                    HighlightedLabel::new(title, highlight_positions)
+                        .size(LabelSize::Small)
+                        .truncate()
+                        .into_any_element()
+                };
+
+                ListItem::new(id)
+                    .toggle_state(is_selected)
+                    .child(
+                        h_flex()
+                            .min_w_0()
+                            .w_full()
+                            .py_1()
+                            .pl_0p5()
+                            .pr_1p5()
+                            .gap_2()
+                            .justify_between()
+                            .child(title_label)
+                            .when_some(timestamp, |this, ts| {
+                                this.child(
+                                    Label::new(ts).size(LabelSize::Small).color(Color::Muted),
+                                )
+                            }),
+                    )
+                    .on_click(cx.listener(move |this, _, window, cx| {
+                        this.open_thread(session_info.clone(), window, cx);
+                    }))
+                    .into_any_element()
+            }
+        }
+    }
+
+    fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
+        let agent_server_store = self.agent_server_store.clone();
+
+        let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
+            (IconName::ChevronUp, Color::Accent)
+        } else {
+            (IconName::ChevronDown, Color::Muted)
+        };
+
+        let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent {
+            let store = agent_server_store.read(cx);
+            let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
+
+            if let Some(icon) = icon {
+                Icon::from_external_svg(icon)
+            } else {
+                Icon::new(IconName::Sparkle)
+            }
+            .color(Color::Muted)
+            .size(IconSize::Small)
+        } else {
+            Icon::new(IconName::ZedAgent)
+                .color(Color::Muted)
+                .size(IconSize::Small)
+        };
+
+        let this = cx.weak_entity();
+
+        PopoverMenu::new("agent_history_menu")
+            .trigger(
+                ButtonLike::new("selected_agent")
+                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                    .child(
+                        h_flex().gap_1().child(selected_agent_icon).child(
+                            Icon::new(chevron_icon)
+                                .color(icon_color)
+                                .size(IconSize::XSmall),
+                        ),
+                    ),
+            )
+            .menu(move |window, cx| {
+                Some(ContextMenu::build(window, cx, |menu, _window, cx| {
+                    menu.item(
+                        ContextMenuEntry::new("Zed Agent")
+                            .icon(IconName::ZedAgent)
+                            .icon_color(Color::Muted)
+                            .handler({
+                                let this = this.clone();
+                                move |window, cx| {
+                                    this.update(cx, |this, cx| {
+                                        this.set_selected_agent(Agent::NativeAgent, window, cx)
+                                    })
+                                    .ok();
+                                }
+                            }),
+                    )
+                    .separator()
+                    .map(|mut menu| {
+                        let agent_server_store = agent_server_store.read(cx);
+                        let registry_store = project::AgentRegistryStore::try_global(cx);
+                        let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
+
+                        struct AgentMenuItem {
+                            id: ExternalAgentServerName,
+                            display_name: SharedString,
+                        }
+
+                        let agent_items = agent_server_store
+                            .external_agents()
+                            .map(|name| {
+                                let display_name = agent_server_store
+                                    .agent_display_name(name)
+                                    .or_else(|| {
+                                        registry_store_ref
+                                            .as_ref()
+                                            .and_then(|store| store.agent(name.0.as_ref()))
+                                            .map(|a| a.name().clone())
+                                    })
+                                    .unwrap_or_else(|| name.0.clone());
+                                AgentMenuItem {
+                                    id: name.clone(),
+                                    display_name,
+                                }
+                            })
+                            .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
+                            .collect::<Vec<_>>();
+
+                        for item in &agent_items {
+                            let mut entry = ContextMenuEntry::new(item.display_name.clone());
+
+                            let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
+                                registry_store_ref
+                                    .as_ref()
+                                    .and_then(|store| store.agent(item.id.0.as_str()))
+                                    .and_then(|a| a.icon_path().cloned())
+                            });
+
+                            if let Some(icon_path) = icon_path {
+                                entry = entry.custom_icon_svg(icon_path);
+                            } else {
+                                entry = entry.icon(IconName::ZedAgent);
+                            }
+
+                            entry = entry.icon_color(Color::Muted).handler({
+                                let this = this.clone();
+                                let agent = Agent::Custom {
+                                    name: item.id.0.clone(),
+                                };
+                                move |window, cx| {
+                                    this.update(cx, |this, cx| {
+                                        this.set_selected_agent(agent.clone(), window, cx)
+                                    })
+                                    .ok();
+                                }
+                            });
+
+                            menu = menu.item(entry);
+                        }
+                        menu
+                    })
+                }))
+            })
+            .with_handle(self.selected_agent_menu.clone())
+            .anchor(gpui::Corner::TopRight)
+            .offset(gpui::Point {
+                x: px(1.0),
+                y: px(1.0),
+            })
+    }
+
+    fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
+
+        h_flex()
+            .h(Tab::container_height(cx))
+            .px_1()
+            .justify_between()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .child(
+                h_flex()
+                    .flex_1()
+                    .w_full()
+                    .gap_1p5()
+                    .child(
+                        IconButton::new("back", IconName::ArrowLeft)
+                            .icon_size(IconSize::Small)
+                            .tooltip(Tooltip::text("Back to Sidebar"))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.go_back(window, cx);
+                            })),
+                    )
+                    .child(self.filter_editor.clone())
+                    .when(has_query, |this| {
+                        this.border_r_1().child(
+                            IconButton::new("clear_archive_filter", IconName::Close)
+                                .icon_size(IconSize::Small)
+                                .tooltip(Tooltip::text("Clear Search"))
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.reset_filter_editor_text(window, cx);
+                                    this.update_items(cx);
+                                })),
+                        )
+                    }),
+            )
+            .child(self.render_agent_picker(cx))
+    }
+}
+
+impl Focusable for ThreadsArchiveView {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Render for ThreadsArchiveView {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_empty = self.items.is_empty();
+        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
+
+        let content = if self.is_loading {
+            v_flex()
+                .flex_1()
+                .justify_center()
+                .items_center()
+                .child(
+                    Icon::new(IconName::LoadCircle)
+                        .size(IconSize::Small)
+                        .color(Color::Muted)
+                        .with_rotate_animation(2),
+                )
+                .into_any_element()
+        } else if is_empty && has_query {
+            v_flex()
+                .flex_1()
+                .justify_center()
+                .items_center()
+                .child(
+                    Label::new("No threads match your search.")
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                )
+                .into_any_element()
+        } else if is_empty {
+            v_flex()
+                .flex_1()
+                .justify_center()
+                .items_center()
+                .child(
+                    Label::new("No archived threads yet.")
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                )
+                .into_any_element()
+        } else {
+            v_flex()
+                .flex_1()
+                .overflow_hidden()
+                .child(
+                    list(
+                        self.list_state.clone(),
+                        cx.processor(Self::render_list_entry),
+                    )
+                    .flex_1()
+                    .size_full(),
+                )
+                .vertical_scrollbar_for(&self.list_state, window, cx)
+                .into_any_element()
+        };
+
+        v_flex()
+            .key_context("ThreadsArchiveView")
+            .track_focus(&self.focus_handle)
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::editor_move_down))
+            .on_action(cx.listener(Self::editor_move_up))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::confirm))
+            .size_full()
+            .bg(cx.theme().colors().surface_background)
+            .child(self.render_header(cx))
+            .child(content)
+    }
+}

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

@@ -193,15 +193,16 @@ impl Render for AcpOnboardingModal {
         let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration.";
 
         let open_panel_button = Button::new("open-panel", "Start with Gemini CLI")
-            .icon_size(IconSize::Indicator)
             .style(ButtonStyle::Tinted(TintColor::Accent))
             .full_width()
             .on_click(cx.listener(Self::open_panel));
 
         let docs_button = Button::new("add-other-agents", "Add Other Agents")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Indicator)
-            .icon_color(Color::Muted)
+            .end_icon(
+                Icon::new(IconName::ArrowUpRight)
+                    .size(IconSize::Indicator)
+                    .color(Color::Muted),
+            )
             .full_width()
             .on_click(cx.listener(Self::open_agent_registry));
 

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

@@ -201,15 +201,16 @@ impl Render for ClaudeCodeOnboardingModal {
         let copy = "Powered by the Agent Client Protocol, you can now run Claude Agent as\na first-class citizen in Zed's agent panel.";
 
         let open_panel_button = Button::new("open-panel", "Start with Claude Agent")
-            .icon_size(IconSize::Indicator)
             .style(ButtonStyle::Tinted(TintColor::Accent))
             .full_width()
             .on_click(cx.listener(Self::open_panel));
 
         let docs_button = Button::new("add-other-agents", "Add Other Agents")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Indicator)
-            .icon_color(Color::Muted)
+            .end_icon(
+                Icon::new(IconName::ArrowUpRight)
+                    .size(IconSize::Indicator)
+                    .color(Color::Muted),
+            )
             .full_width()
             .on_click(cx.listener(Self::view_docs));
 

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

@@ -4,20 +4,31 @@ use ui::{prelude::*, render_modifiers};
 #[derive(IntoElement)]
 pub struct HoldForDefault {
     is_default: bool,
+    more_content: bool,
 }
 
 impl HoldForDefault {
     pub fn new(is_default: bool) -> Self {
-        Self { is_default }
+        Self {
+            is_default,
+            more_content: true,
+        }
+    }
+
+    pub fn more_content(mut self, more_content: bool) -> Self {
+        self.more_content = more_content;
+        self
     }
 }
 
 impl RenderOnce for HoldForDefault {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         h_flex()
-            .pt_1()
-            .border_t_1()
-            .border_color(cx.theme().colors().border_variant)
+            .when(self.more_content, |this| {
+                this.pt_1()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant)
+            })
             .gap_0p5()
             .text_sm()
             .text_color(Color::Muted.color(cx))

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

@@ -13,6 +13,8 @@ use theme::ThemeSettings;
 use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
 use workspace::{OpenOptions, Workspace};
 
+use crate::Agent;
+
 #[derive(IntoElement)]
 pub struct MentionCrease {
     id: ElementId,
@@ -187,7 +189,8 @@ fn open_mention_uri(
         | MentionUri::Selection { abs_path: None, .. }
         | MentionUri::Diagnostics { .. }
         | MentionUri::TerminalSelection { .. }
-        | MentionUri::GitDiff { .. } => {}
+        | MentionUri::GitDiff { .. }
+        | MentionUri::MergeConflict { .. } => {}
     });
 }
 
@@ -269,21 +272,19 @@ fn open_thread(
     cx: &mut Context<Workspace>,
 ) {
     use crate::AgentPanel;
-    use acp_thread::AgentSessionInfo;
 
     let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
         return;
     };
 
+    // Right now we only support loading threads in the native agent
     panel.update(cx, |panel, cx| {
         panel.load_agent_thread(
-            AgentSessionInfo {
-                session_id: id,
-                cwd: None,
-                title: Some(name.into()),
-                updated_at: None,
-                meta: None,
-            },
+            Agent::NativeAgent,
+            id,
+            None,
+            Some(name.into()),
+            true,
             window,
             cx,
         )

crates/ai_onboarding/src/ai_onboarding.rs 🔗

@@ -266,6 +266,20 @@ impl ZedAiOnboarding {
             .into_any_element()
     }
 
+    fn render_business_plan_state(&self, _cx: &mut App) -> AnyElement {
+        v_flex()
+            .gap_1()
+            .child(Headline::new("Welcome to Zed Business"))
+            .child(
+                Label::new("Here's what you get:")
+                    .color(Color::Muted)
+                    .mb_2(),
+            )
+            .child(PlanDefinitions.business_plan())
+            .children(self.render_dismiss_button())
+            .into_any_element()
+    }
+
     fn render_student_plan_state(&self, _cx: &mut App) -> AnyElement {
         v_flex()
             .gap_1()
@@ -289,6 +303,7 @@ impl RenderOnce for ZedAiOnboarding {
                 Some(Plan::ZedFree) => self.render_free_plan_state(cx),
                 Some(Plan::ZedProTrial) => self.render_trial_state(cx),
                 Some(Plan::ZedPro) => self.render_pro_plan_state(cx),
+                Some(Plan::ZedBusiness) => self.render_business_plan_state(cx),
                 Some(Plan::ZedStudent) => self.render_student_plan_state(cx),
             }
         } else {
@@ -353,6 +368,14 @@ impl Component for ZedAiOnboarding {
                         "Pro Plan",
                         onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false),
                     ),
+                    single_example(
+                        "Business Plan",
+                        onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false),
+                    ),
+                    single_example(
+                        "Student Plan",
+                        onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false),
+                    ),
                 ])
                 .into_any_element(),
         )

crates/ai_onboarding/src/ai_upsell_card.rs 🔗

@@ -250,6 +250,15 @@ impl RenderOnce for AiUpsellCard {
                             .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))
@@ -368,6 +377,28 @@ impl Component for AiUpsellCard {
                             }
                             .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(),

crates/ai_onboarding/src/plan_definitions.rs 🔗

@@ -36,6 +36,12 @@ impl PlanDefinitions {
             .child(ListBulletItem::new("Usage-based billing beyond $5"))
     }
 
+    pub fn business_plan(&self) -> impl IntoElement {
+        List::new()
+            .child(ListBulletItem::new("Unlimited edit predictions"))
+            .child(ListBulletItem::new("Usage-based billing"))
+    }
+
     pub fn student_plan(&self) -> impl IntoElement {
         List::new()
             .child(ListBulletItem::new("Unlimited edit predictions"))

crates/anthropic/Cargo.toml 🔗

@@ -27,8 +27,4 @@ settings.workspace = true
 strum.workspace = true
 thiserror.workspace = true
 
-[dev-dependencies]
-reqwest_client.workspace = true
-gpui_tokio.workspace = true
-gpui.workspace = true
-tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+

crates/anthropic/src/anthropic.rs 🔗

@@ -995,7 +995,7 @@ pub enum Speed {
 }
 
 #[derive(Debug, Serialize, Deserialize)]
-struct StreamingRequest {
+pub struct StreamingRequest {
     #[serde(flatten)]
     pub base: Request,
     pub stream: bool,

crates/assistant_text_thread/Cargo.toml 🔗

@@ -55,7 +55,7 @@ zed_env_vars.workspace = true
 
 [dev-dependencies]
 assistant_slash_commands.workspace = true
-indoc.workspace = true
+
 language_model = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 rand.workspace = true

crates/assistant_text_thread/src/text_thread.rs 🔗

@@ -1219,7 +1219,7 @@ impl TextThread {
             } => cx.emit(TextThreadEvent::Operation(
                 TextThreadOperation::BufferOperation(operation.clone()),
             )),
-            language::BufferEvent::Edited => {
+            language::BufferEvent::Edited { .. } => {
                 self.count_remaining_tokens(cx);
                 self.reparse(cx);
                 cx.emit(TextThreadEvent::MessagesEdited);

crates/audio/src/audio.rs 🔗

@@ -384,17 +384,29 @@ pub fn open_input_stream(
     Ok(stream)
 }
 
-pub fn open_output_stream(device_id: Option<DeviceId>) -> anyhow::Result<MixerDeviceSink> {
-    let output_handle = if let Some(id) = device_id {
-        if let Some(device) = default_host().device_by_id(&id) {
-            DeviceSinkBuilder::from_device(device)?.open_stream()
-        } else {
-            DeviceSinkBuilder::open_default_sink()
+pub fn resolve_device(device_id: Option<&DeviceId>, input: bool) -> anyhow::Result<cpal::Device> {
+    if let Some(id) = device_id {
+        if let Some(device) = default_host().device_by_id(id) {
+            return Ok(device);
         }
+        log::warn!("Selected audio device not found, falling back to default");
+    }
+    if input {
+        default_host()
+            .default_input_device()
+            .context("no audio input device available")
     } else {
-        DeviceSinkBuilder::open_default_sink()
-    };
-    let mut output_handle = output_handle.context("Could not open output stream")?;
+        default_host()
+            .default_output_device()
+            .context("no audio output device available")
+    }
+}
+
+pub fn open_output_stream(device_id: Option<DeviceId>) -> anyhow::Result<MixerDeviceSink> {
+    let device = resolve_device(device_id.as_ref(), false)?;
+    let mut output_handle = DeviceSinkBuilder::from_device(device)?
+        .open_stream()
+        .context("Could not open output stream")?;
     output_handle.log_on_drop(false);
     log::info!("Output stream: {:?}", output_handle);
     Ok(output_handle)

crates/audio/src/audio_settings.rs 🔗

@@ -42,12 +42,8 @@ pub struct AudioSettings {
     ///
     /// You need to rejoin a call for this setting to apply
     pub legacy_audio_compatible: bool,
-    /// Requires 'rodio_audio: true'
-    ///
     /// Select specific output audio device.
     pub output_audio_device: Option<DeviceId>,
-    /// Requires 'rodio_audio: true'
-    ///
     /// Select specific input audio device.
     pub input_audio_device: Option<DeviceId>,
 }

crates/auto_update/src/auto_update.rs 🔗

@@ -212,18 +212,10 @@ pub fn init(client: Arc<Client>, cx: &mut App) {
 }
 
 pub fn check(_: &Check, window: &mut Window, cx: &mut App) {
-    if let Some(message) = option_env!("ZED_UPDATE_EXPLANATION") {
-        drop(window.prompt(
-            gpui::PromptLevel::Info,
-            "Zed was installed via a package manager.",
-            Some(message),
-            &["Ok"],
-            cx,
-        ));
-        return;
-    }
-
-    if let Ok(message) = env::var("ZED_UPDATE_EXPLANATION") {
+    if let Some(message) = option_env!("ZED_UPDATE_EXPLANATION")
+        .map(ToOwned::to_owned)
+        .or_else(|| env::var("ZED_UPDATE_EXPLANATION").ok())
+    {
         drop(window.prompt(
             gpui::PromptLevel::Info,
             "Zed was installed via a package manager.",
@@ -388,6 +380,10 @@ impl AutoUpdater {
 
     pub fn poll(&mut self, check_type: UpdateCheckType, cx: &mut Context<Self>) {
         if self.pending_poll.is_some() {
+            if self.update_check_type == UpdateCheckType::Automatic {
+                self.update_check_type = check_type;
+                cx.notify();
+            }
             return;
         }
         self.update_check_type = check_type;
@@ -557,7 +553,7 @@ impl AutoUpdater {
                 asset,
                 metrics_id: metrics_id.as_deref(),
                 system_id: system_id.as_deref(),
-                is_staff: is_staff,
+                is_staff,
             },
         )?;
 

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -1,14 +1,15 @@
 use gpui::{
-    AnyElement, App, Context, EventEmitter, Global, IntoElement, Render, Subscription, Window,
+    AnyElement, App, Context, EventEmitter, Font, Global, IntoElement, Render, Subscription, Window,
 };
 use ui::prelude::*;
 use workspace::{
     ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
-    item::{BreadcrumbText, ItemEvent, ItemHandle},
+    item::{HighlightedText, ItemEvent, ItemHandle},
 };
 
 type RenderBreadcrumbTextFn = fn(
-    Vec<BreadcrumbText>,
+    Vec<HighlightedText>,
+    Option<Font>,
     Option<AnyElement>,
     &dyn ItemHandle,
     bool,
@@ -57,7 +58,7 @@ impl Render for Breadcrumbs {
             return element.into_any_element();
         };
 
-        let Some(segments) = active_item.breadcrumbs(cx) else {
+        let Some((segments, breadcrumb_font)) = active_item.breadcrumbs(cx) else {
             return element.into_any_element();
         };
 
@@ -66,6 +67,7 @@ impl Render for Breadcrumbs {
         if let Some(render_fn) = cx.try_global::<RenderBreadcrumbText>() {
             (render_fn.0)(
                 segments,
+                breadcrumb_font,
                 prefix_element,
                 active_item.as_ref(),
                 false,

crates/buffer_diff/Cargo.toml 🔗

@@ -34,7 +34,7 @@ ztracing.workspace = true
 ctor.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 rand.workspace = true
-serde_json.workspace = true
+
 settings.workspace = true
 text = { workspace = true, features = ["test-support"] }
 unindent.workspace = true

crates/call/Cargo.toml 🔗

@@ -51,5 +51,5 @@ gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
-http_client = { workspace = true, features = ["test-support"] }
+
 livekit_client = { workspace = true, features = ["test-support"] }

crates/channel/src/channel_buffer.rs 🔗

@@ -221,7 +221,7 @@ impl ChannelBuffer {
                     })
                     .log_err();
             }
-            language::BufferEvent::Edited => {
+            language::BufferEvent::Edited { .. } => {
                 cx.emit(ChannelBufferEvent::BufferEdited);
             }
             _ => {}

crates/channel/src/channel_store.rs 🔗

@@ -156,6 +156,10 @@ impl ChannelStore {
         cx.global::<GlobalChannelStore>().0.clone()
     }
 
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalChannelStore>().map(|g| g.0.clone())
+    }
+
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
         let rpc_subscriptions = [
             client.add_message_handler(cx.weak_entity(), Self::handle_update_channels),

crates/cli/src/cli.rs 🔗

@@ -34,4 +34,7 @@ pub enum CliResponse {
 
 /// When Zed started not as an *.app but as a binary (e.g. local development),
 /// there's a possibility to tell it to behave "regularly".
+///
+/// Note that in the main zed binary, this variable is unset after it's read for the first time,
+/// therefore it should always be accessed through the `FORCE_CLI_MODE` static.
 pub const FORCE_CLI_MODE_ENV_VAR_NAME: &str = "ZED_FORCE_CLI_MODE";

crates/client/src/test.rs 🔗

@@ -1,4 +1,6 @@
-use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore};
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
 use anyhow::{Context as _, Result, anyhow};
 use cloud_api_client::{
     AuthenticatedUser, GetAuthenticatedUserResponse, KnownOrUnknown, Plan, PlanInfo,
@@ -9,7 +11,8 @@ use gpui::{AppContext as _, Entity, TestAppContext};
 use http_client::{AsyncBody, Method, Request, http};
 use parking_lot::Mutex;
 use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto};
-use std::sync::Arc;
+
+use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore};
 
 pub struct FakeServer {
     peer: Arc<Peer>,
@@ -266,6 +269,7 @@ pub fn make_get_authenticated_user_response(
         },
         feature_flags: vec![],
         organizations: vec![],
+        plans_by_organization: BTreeMap::new(),
         plan: PlanInfo {
             plan: KnownOrUnknown::Known(Plan::ZedPro),
             subscription_period: None,

crates/client/src/user.rs 🔗

@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result};
 use chrono::{DateTime, Utc};
 use cloud_api_client::websocket_protocol::MessageToClient;
 use cloud_api_client::{
-    GetAuthenticatedUserResponse, Organization, OrganizationId, Plan, PlanInfo,
+    GetAuthenticatedUserResponse, KnownOrUnknown, Organization, OrganizationId, Plan, PlanInfo,
 };
 use cloud_llm_client::{
     EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
@@ -140,6 +140,7 @@ pub enum Event {
     ParticipantIndicesChanged,
     PrivateUserInfoUpdated,
     PlanUpdated,
+    OrganizationChanged,
 }
 
 #[derive(Clone, Copy)]
@@ -694,8 +695,21 @@ impl UserStore {
         self.current_organization.clone()
     }
 
-    pub fn set_current_organization(&mut self, organization: Arc<Organization>) {
-        self.current_organization.replace(organization);
+    pub fn set_current_organization(
+        &mut self,
+        organization: Arc<Organization>,
+        cx: &mut Context<Self>,
+    ) {
+        let is_same_organization = self
+            .current_organization
+            .as_ref()
+            .is_some_and(|current| current.id == organization.id);
+
+        if !is_same_organization {
+            self.current_organization.replace(organization);
+            cx.emit(Event::OrganizationChanged);
+            cx.notify();
+        }
     }
 
     pub fn organizations(&self) -> &Vec<Arc<Organization>> {
@@ -803,6 +817,21 @@ impl UserStore {
 
         self.organizations = response.organizations.into_iter().map(Arc::new).collect();
         self.current_organization = self.organizations.first().cloned();
+        self.plans_by_organization = response
+            .plans_by_organization
+            .into_iter()
+            .map(|(organization_id, plan)| {
+                let plan = match plan {
+                    KnownOrUnknown::Known(plan) => plan,
+                    KnownOrUnknown::Unknown(_) => {
+                        // If we get a plan that we don't recognize, fall back to the Free plan.
+                        Plan::ZedFree
+                    }
+                };
+
+                (organization_id, plan)
+            })
+            .collect();
 
         self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage {
             limit: response.plan.usage.edit_predictions.limit,

crates/cloud_api_types/src/cloud_api_types.rs 🔗

@@ -4,6 +4,7 @@ mod plan;
 mod timestamp;
 pub mod websocket_protocol;
 
+use std::collections::BTreeMap;
 use std::sync::Arc;
 
 use serde::{Deserialize, Serialize};
@@ -21,6 +22,8 @@ pub struct GetAuthenticatedUserResponse {
     pub feature_flags: Vec<String>,
     #[serde(default)]
     pub organizations: Vec<Organization>,
+    #[serde(default)]
+    pub plans_by_organization: BTreeMap<OrganizationId, KnownOrUnknown<Plan, String>>,
     pub plan: PlanInfo,
 }
 
@@ -35,7 +38,7 @@ pub struct AuthenticatedUser {
     pub accepted_tos_at: Option<Timestamp>,
 }
 
-#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
 pub struct OrganizationId(pub Arc<str>);
 
 #[derive(Debug, PartialEq, Serialize, Deserialize)]

crates/cloud_llm_client/Cargo.toml 🔗

@@ -22,6 +22,4 @@ strum = { workspace = true, features = ["derive"] }
 uuid = { workspace = true, features = ["serde"] }
 zeta_prompt.workspace = true
 
-[dev-dependencies]
-pretty_assertions.workspace = true
-indoc.workspace = true
+

crates/codestral/Cargo.toml 🔗

@@ -22,5 +22,6 @@ log.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 text.workspace = true
+zeta_prompt.workspace = true
 
 [dev-dependencies]

crates/codestral/src/codestral.rs 🔗

@@ -8,7 +8,7 @@ use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task};
 use http_client::HttpClient;
 use icons::IconName;
 use language::{
-    Anchor, Buffer, BufferSnapshot, EditPreview, ToPoint, language_settings::all_language_settings,
+    Anchor, Buffer, BufferSnapshot, EditPreview, language_settings::all_language_settings,
 };
 use language_model::{ApiKeyState, AuthenticateError, EnvVar, env_var};
 use serde::{Deserialize, Serialize};
@@ -18,7 +18,7 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use text::{OffsetRangeExt as _, ToOffset};
+use text::ToOffset;
 
 pub const CODESTRAL_API_URL: &str = "https://codestral.mistral.ai";
 pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150);
@@ -259,28 +259,31 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate {
             }
 
             let cursor_offset = cursor_position.to_offset(&snapshot);
-            let cursor_point = cursor_offset.to_point(&snapshot);
 
+            const MAX_EDITABLE_TOKENS: usize = 350;
             const MAX_CONTEXT_TOKENS: usize = 150;
-            const MAX_REWRITE_TOKENS: usize = 350;
-
-            let (_, context_range) =
-                cursor_excerpt::editable_and_context_ranges_for_cursor_position(
-                    cursor_point,
-                    &snapshot,
-                    MAX_REWRITE_TOKENS,
-                    MAX_CONTEXT_TOKENS,
-                );
-
-            let context_range = context_range.to_offset(&snapshot);
-            let excerpt_text = snapshot
-                .text_for_range(context_range.clone())
-                .collect::<String>();
-            let cursor_within_excerpt = cursor_offset
+
+            let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
+                cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset);
+            let syntax_ranges = cursor_excerpt::compute_syntax_ranges(
+                &snapshot,
+                cursor_offset,
+                &excerpt_offset_range,
+            );
+            let excerpt_text: String = snapshot.text_for_range(excerpt_point_range).collect();
+            let (_, context_range) = zeta_prompt::compute_editable_and_context_ranges(
+                &excerpt_text,
+                cursor_offset_in_excerpt,
+                &syntax_ranges,
+                MAX_EDITABLE_TOKENS,
+                MAX_CONTEXT_TOKENS,
+            );
+            let context_text = &excerpt_text[context_range.clone()];
+            let cursor_within_excerpt = cursor_offset_in_excerpt
                 .saturating_sub(context_range.start)
-                .min(excerpt_text.len());
-            let prompt = excerpt_text[..cursor_within_excerpt].to_string();
-            let suffix = excerpt_text[cursor_within_excerpt..].to_string();
+                .min(context_text.len());
+            let prompt = context_text[..cursor_within_excerpt].to_string();
+            let suffix = context_text[cursor_within_excerpt..].to_string();
 
             let completion_text = match Self::fetch_completion(
                 http_client,

crates/collab/Cargo.toml 🔗

@@ -75,13 +75,13 @@ uuid.workspace = true
 
 [dev-dependencies]
 agent = { workspace = true, features = ["test-support"] }
-agent-client-protocol.workspace = true
-agent_settings.workspace = true
-agent_ui = { workspace = true, features = ["test-support"] }
+
+
+
 assistant_text_thread.workspace = true
 assistant_slash_command.workspace = true
 async-trait.workspace = true
-audio.workspace = true
+
 buffer_diff.workspace = true
 call = { workspace = true, features = ["test-support"] }
 channel.workspace = true
@@ -90,11 +90,11 @@ collab = { workspace = true, features = ["test-support"] }
 collab_ui = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
 command_palette_hooks.workspace = true
-context_server.workspace = true
+
 ctor.workspace = true
 dap = { workspace = true, features = ["test-support"] }
 dap_adapters = { workspace = true, features = ["test-support"] }
-dap-types.workspace = true
+
 debugger_ui = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 extension.workspace = true
@@ -105,7 +105,7 @@ git_hosting_providers.workspace = true
 git_ui = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 gpui_tokio.workspace = true
-hyper.workspace = true
+
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, features = ["test-support"] }
@@ -131,7 +131,7 @@ smol.workspace = true
 sqlx = { version = "0.8", features = ["sqlite"] }
 task.workspace = true
 theme.workspace = true
-title_bar = { workspace = true, features = ["test-support"] }
+
 unindent.workspace = true
 util.workspace = true
 workspace = { workspace = true, features = ["test-support"] }

crates/collab/migrations/20251208000000_test_schema.sql 🔗

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

crates/collab/src/db/queries/projects.rs 🔗

@@ -374,6 +374,9 @@ impl Database {
                 merge_message: ActiveValue::set(update.merge_message.clone()),
                 remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()),
                 remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()),
+                linked_worktrees: ActiveValue::Set(Some(
+                    serde_json::to_string(&update.linked_worktrees).unwrap(),
+                )),
             })
             .on_conflict(
                 OnConflict::columns([
@@ -388,6 +391,7 @@ impl Database {
                     project_repository::Column::CurrentMergeConflicts,
                     project_repository::Column::HeadCommitDetails,
                     project_repository::Column::MergeMessage,
+                    project_repository::Column::LinkedWorktrees,
                 ])
                 .to_owned(),
             )
@@ -883,6 +887,11 @@ impl Database {
                         remote_upstream_url: db_repository_entry.remote_upstream_url.clone(),
                         remote_origin_url: db_repository_entry.remote_origin_url.clone(),
                         original_repo_abs_path: Some(db_repository_entry.abs_path),
+                        linked_worktrees: db_repository_entry
+                            .linked_worktrees
+                            .as_deref()
+                            .and_then(|s| serde_json::from_str(s).ok())
+                            .unwrap_or_default(),
                     });
                 }
             }

crates/collab/src/db/queries/rooms.rs 🔗

@@ -799,6 +799,11 @@ impl Database {
                             remote_upstream_url: db_repository.remote_upstream_url.clone(),
                             remote_origin_url: db_repository.remote_origin_url.clone(),
                             original_repo_abs_path: Some(db_repository.abs_path),
+                            linked_worktrees: db_repository
+                                .linked_worktrees
+                                .as_deref()
+                                .and_then(|s| serde_json::from_str(s).ok())
+                                .unwrap_or_default(),
                         });
                     }
                 }

crates/collab/src/db/tables/project_repository.rs 🔗

@@ -24,6 +24,8 @@ pub struct Model {
     pub head_commit_details: Option<String>,
     pub remote_upstream_url: Option<String>,
     pub remote_origin_url: Option<String>,
+    // JSON array of linked worktree objects
+    pub linked_worktrees: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/collab/src/rpc.rs 🔗

@@ -439,6 +439,8 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::GitRemoveRemote>)
             .add_request_handler(forward_read_only_project_request::<proto::GitGetWorktrees>)
             .add_request_handler(forward_mutating_project_request::<proto::GitCreateWorktree>)
+            .add_request_handler(disallow_guest_request::<proto::GitRemoveWorktree>)
+            .add_request_handler(disallow_guest_request::<proto::GitRenameWorktree>)
             .add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
             .add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)
             .add_message_handler(update_context)
@@ -2250,6 +2252,24 @@ where
     Ok(())
 }
 
+async fn disallow_guest_request<T>(
+    _request: T,
+    response: Response<T>,
+    _session: MessageContext,
+) -> Result<()>
+where
+    T: RequestMessage,
+{
+    response.peer.respond_with_error(
+        response.receipt,
+        ErrorCode::Forbidden
+            .message("request is not allowed for guests".to_string())
+            .to_proto(),
+    )?;
+    response.responded.store(true, SeqCst);
+    Ok(())
+}
+
 async fn lsp_query(
     request: proto::LspQuery,
     response: Response<proto::LspQuery>,

crates/collab/tests/integration/editor_tests.rs 🔗

@@ -4721,6 +4721,54 @@ async fn test_copy_file_location(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo
         cx_b.read_from_clipboard().and_then(|item| item.text()),
         Some(format!("{}:2", path!("src/main.rs")))
     );
+
+    editor_a.update_in(cx_a, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |s| {
+            s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+
+    assert_eq!(
+        cx_a.read_from_clipboard().and_then(|item| item.text()),
+        Some(format!("{}:2-3", path!("src/main.rs")))
+    );
+
+    editor_b.update_in(cx_b, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |s| {
+            s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+
+    assert_eq!(
+        cx_b.read_from_clipboard().and_then(|item| item.text()),
+        Some(format!("{}:2-3", path!("src/main.rs")))
+    );
+
+    editor_a.update_in(cx_a, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |s| {
+            s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+
+    assert_eq!(
+        cx_a.read_from_clipboard().and_then(|item| item.text()),
+        Some(format!("{}:2", path!("src/main.rs")))
+    );
+
+    editor_b.update_in(cx_b, |editor, window, cx| {
+        editor.change_selections(Default::default(), window, cx, |s| {
+            s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]);
+        });
+        editor.copy_file_location(&CopyFileLocation, window, cx);
+    });
+
+    assert_eq!(
+        cx_b.read_from_clipboard().and_then(|item| item.text()),
+        Some(format!("{}:2", path!("src/main.rs")))
+    );
 }
 
 #[track_caller]
@@ -5643,7 +5691,7 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont
     executor.run_until_parked();
 
     editor_a.update(cx_a, |editor, cx| {
-        let breadcrumbs = editor
+        let (breadcrumbs, _) = editor
             .breadcrumbs(cx)
             .expect("Host should have breadcrumbs");
         let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect();
@@ -5679,6 +5727,7 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont
             editor
                 .breadcrumbs(cx)
                 .expect("Client B should have breadcrumbs")
+                .0
                 .iter()
                 .map(|b| b.text.as_str())
                 .collect::<Vec<_>>(),

crates/collab/tests/integration/git_tests.rs 🔗

@@ -1,9 +1,10 @@
 use std::path::{Path, PathBuf};
 
 use call::ActiveCall;
+use client::RECEIVE_TIMEOUT;
 use collections::HashMap;
 use git::{
-    repository::RepoPath,
+    repository::{RepoPath, Worktree as GitWorktree},
     status::{DiffStat, FileStatus, StatusCode, TrackedStatus},
 };
 use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff};
@@ -302,6 +303,297 @@ async fn test_remote_git_worktrees(
         worktree_directory.join("bugfix-branch")
     );
     assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha");
+
+    // Client B (guest) attempts to rename a worktree. This should fail
+    // because worktree renaming is not forwarded through collab
+    let rename_result = cx_b
+        .update(|cx| {
+            repo_b.update(cx, |repository, _| {
+                repository.rename_worktree(
+                    worktree_directory.join("feature-branch"),
+                    worktree_directory.join("renamed-branch"),
+                )
+            })
+        })
+        .await
+        .unwrap();
+    assert!(
+        rename_result.is_err(),
+        "Guest should not be able to rename worktrees via collab"
+    );
+
+    executor.run_until_parked();
+
+    // Verify worktrees are unchanged — still 3
+    let worktrees = cx_b
+        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        worktrees.len(),
+        3,
+        "Worktree count should be unchanged after failed rename"
+    );
+
+    // Client B (guest) attempts to remove a worktree. This should fail
+    // because worktree removal is not forwarded through collab
+    let remove_result = cx_b
+        .update(|cx| {
+            repo_b.update(cx, |repository, _| {
+                repository.remove_worktree(worktree_directory.join("feature-branch"), false)
+            })
+        })
+        .await
+        .unwrap();
+    assert!(
+        remove_result.is_err(),
+        "Guest should not be able to remove worktrees via collab"
+    );
+
+    executor.run_until_parked();
+
+    // Verify worktrees are unchanged — still 3
+    let worktrees = cx_b
+        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        worktrees.len(),
+        3,
+        "Worktree count should be unchanged after failed removal"
+    );
+}
+
+#[gpui::test]
+async fn test_linked_worktrees_sync(
+    executor: BackgroundExecutor,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+) {
+    let mut server = TestServer::start(executor.clone()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    // Set up a git repo with two linked worktrees already present.
+    client_a
+        .fs()
+        .insert_tree(
+            path!("/project"),
+            json!({ ".git": {}, "file.txt": "content" }),
+        )
+        .await;
+
+    client_a
+        .fs()
+        .with_git_state(Path::new(path!("/project/.git")), true, |state| {
+            state.worktrees.push(GitWorktree {
+                path: PathBuf::from(path!("/project")),
+                ref_name: "refs/heads/main".into(),
+                sha: "aaa111".into(),
+            });
+            state.worktrees.push(GitWorktree {
+                path: PathBuf::from(path!("/project/feature-branch")),
+                ref_name: "refs/heads/feature-branch".into(),
+                sha: "bbb222".into(),
+            });
+            state.worktrees.push(GitWorktree {
+                path: PathBuf::from(path!("/project/bugfix-branch")),
+                ref_name: "refs/heads/bugfix-branch".into(),
+                sha: "ccc333".into(),
+            });
+        })
+        .unwrap();
+
+    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
+
+    // Wait for git scanning to complete on the host.
+    executor.run_until_parked();
+
+    // Verify the host sees 2 linked worktrees (main worktree is filtered out).
+    let host_linked = project_a.read_with(cx_a, |project, cx| {
+        let repos = project.repositories(cx);
+        assert_eq!(repos.len(), 1, "host should have exactly 1 repository");
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        host_linked.len(),
+        2,
+        "host should have 2 linked worktrees (main filtered out)"
+    );
+    assert_eq!(
+        host_linked[0].path,
+        PathBuf::from(path!("/project/feature-branch"))
+    );
+    assert_eq!(
+        host_linked[0].ref_name.as_ref(),
+        "refs/heads/feature-branch"
+    );
+    assert_eq!(host_linked[0].sha.as_ref(), "bbb222");
+    assert_eq!(
+        host_linked[1].path,
+        PathBuf::from(path!("/project/bugfix-branch"))
+    );
+    assert_eq!(host_linked[1].ref_name.as_ref(), "refs/heads/bugfix-branch");
+    assert_eq!(host_linked[1].sha.as_ref(), "ccc333");
+
+    // Share the project and have client B join.
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.join_remote_project(project_id, cx_b).await;
+
+    executor.run_until_parked();
+
+    // Verify the guest sees the same linked worktrees as the host.
+    let guest_linked = project_b.read_with(cx_b, |project, cx| {
+        let repos = project.repositories(cx);
+        assert_eq!(repos.len(), 1, "guest should have exactly 1 repository");
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        guest_linked, host_linked,
+        "guest's linked_worktrees should match host's after initial sync"
+    );
+
+    // Now mutate: add a third linked worktree on the host side.
+    client_a
+        .fs()
+        .with_git_state(Path::new(path!("/project/.git")), true, |state| {
+            state.worktrees.push(GitWorktree {
+                path: PathBuf::from(path!("/project/hotfix-branch")),
+                ref_name: "refs/heads/hotfix-branch".into(),
+                sha: "ddd444".into(),
+            });
+        })
+        .unwrap();
+
+    // Wait for the host to re-scan and propagate the update.
+    executor.run_until_parked();
+
+    // Verify host now sees 3 linked worktrees.
+    let host_linked_updated = project_a.read_with(cx_a, |project, cx| {
+        let repos = project.repositories(cx);
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        host_linked_updated.len(),
+        3,
+        "host should now have 3 linked worktrees"
+    );
+    assert_eq!(
+        host_linked_updated[2].path,
+        PathBuf::from(path!("/project/hotfix-branch"))
+    );
+
+    // Verify the guest also received the update.
+    let guest_linked_updated = project_b.read_with(cx_b, |project, cx| {
+        let repos = project.repositories(cx);
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        guest_linked_updated, host_linked_updated,
+        "guest's linked_worktrees should match host's after update"
+    );
+
+    // Now mutate: remove one linked worktree from the host side.
+    client_a
+        .fs()
+        .with_git_state(Path::new(path!("/project/.git")), true, |state| {
+            state
+                .worktrees
+                .retain(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch");
+        })
+        .unwrap();
+
+    executor.run_until_parked();
+
+    // Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch).
+    let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| {
+        let repos = project.repositories(cx);
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        host_linked_after_removal.len(),
+        2,
+        "host should have 2 linked worktrees after removal"
+    );
+    assert!(
+        host_linked_after_removal
+            .iter()
+            .all(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"),
+        "bugfix-branch should have been removed"
+    );
+
+    // Verify the guest also reflects the removal.
+    let guest_linked_after_removal = project_b.read_with(cx_b, |project, cx| {
+        let repos = project.repositories(cx);
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        guest_linked_after_removal, host_linked_after_removal,
+        "guest's linked_worktrees should match host's after removal"
+    );
+
+    // Test DB roundtrip: client C joins late, getting state from the database.
+    // This verifies that linked_worktrees are persisted and restored correctly.
+    let project_c = client_c.join_remote_project(project_id, cx_c).await;
+    executor.run_until_parked();
+
+    let late_joiner_linked = project_c.read_with(cx_c, |project, cx| {
+        let repos = project.repositories(cx);
+        assert_eq!(
+            repos.len(),
+            1,
+            "late joiner should have exactly 1 repository"
+        );
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        late_joiner_linked, host_linked_after_removal,
+        "late-joining client's linked_worktrees should match host's (DB roundtrip)"
+    );
+
+    // Test reconnection: disconnect client B (guest) and reconnect.
+    // After rejoining, client B should get linked_worktrees back from the DB.
+    server.disconnect_client(client_b.peer_id().unwrap());
+    executor.advance_clock(RECEIVE_TIMEOUT);
+    executor.run_until_parked();
+
+    // Client B reconnects automatically.
+    executor.advance_clock(RECEIVE_TIMEOUT);
+    executor.run_until_parked();
+
+    // Verify client B still has the correct linked worktrees after reconnection.
+    let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| {
+        let repos = project.repositories(cx);
+        assert_eq!(
+            repos.len(),
+            1,
+            "guest should still have exactly 1 repository after reconnect"
+        );
+        let repo = repos.values().next().unwrap();
+        repo.read(cx).linked_worktrees().to_vec()
+    });
+    assert_eq!(
+        guest_linked_after_reconnect, host_linked_after_removal,
+        "guest's linked_worktrees should survive guest disconnect/reconnect"
+    );
 }
 
 #[gpui::test]

crates/collab/tests/integration/remote_editing_collaboration_tests.rs 🔗

@@ -518,6 +518,122 @@ async fn test_ssh_collaboration_git_worktrees(
         server_worktrees[1].path,
         worktree_directory.join("feature-branch")
     );
+
+    // Host (client A) renames the worktree via SSH
+    let repo_a = cx_a.update(|cx| {
+        project_a
+            .read(cx)
+            .repositories(cx)
+            .values()
+            .next()
+            .unwrap()
+            .clone()
+    });
+    cx_a.update(|cx| {
+        repo_a.update(cx, |repository, _| {
+            repository.rename_worktree(
+                PathBuf::from("/project/feature-branch"),
+                PathBuf::from("/project/renamed-branch"),
+            )
+        })
+    })
+    .await
+    .unwrap()
+    .unwrap();
+
+    executor.run_until_parked();
+
+    let host_worktrees = cx_a
+        .update(|cx| repo_a.update(cx, |repository, _| repository.worktrees()))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        host_worktrees.len(),
+        2,
+        "Host should still have 2 worktrees after rename"
+    );
+    assert_eq!(
+        host_worktrees[1].path,
+        PathBuf::from("/project/renamed-branch")
+    );
+
+    let server_worktrees = {
+        let server_repo = server_cx.update(|cx| {
+            headless_project.update(cx, |headless_project, cx| {
+                headless_project
+                    .git_store
+                    .read(cx)
+                    .repositories()
+                    .values()
+                    .next()
+                    .unwrap()
+                    .clone()
+            })
+        });
+        server_cx
+            .update(|cx| server_repo.update(cx, |repo, _| repo.worktrees()))
+            .await
+            .unwrap()
+            .unwrap()
+    };
+    assert_eq!(
+        server_worktrees.len(),
+        2,
+        "Server should still have 2 worktrees after rename"
+    );
+    assert_eq!(
+        server_worktrees[1].path,
+        PathBuf::from("/project/renamed-branch")
+    );
+
+    // Host (client A) removes the renamed worktree via SSH
+    cx_a.update(|cx| {
+        repo_a.update(cx, |repository, _| {
+            repository.remove_worktree(PathBuf::from("/project/renamed-branch"), false)
+        })
+    })
+    .await
+    .unwrap()
+    .unwrap();
+
+    executor.run_until_parked();
+
+    let host_worktrees = cx_a
+        .update(|cx| repo_a.update(cx, |repository, _| repository.worktrees()))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        host_worktrees.len(),
+        1,
+        "Host should only have the main worktree after removal"
+    );
+
+    let server_worktrees = {
+        let server_repo = server_cx.update(|cx| {
+            headless_project.update(cx, |headless_project, cx| {
+                headless_project
+                    .git_store
+                    .read(cx)
+                    .repositories()
+                    .values()
+                    .next()
+                    .unwrap()
+                    .clone()
+            })
+        });
+        server_cx
+            .update(|cx| server_repo.update(cx, |repo, _| repo.worktrees()))
+            .await
+            .unwrap()
+            .unwrap()
+    };
+    assert_eq!(
+        server_worktrees.len(),
+        1,
+        "Server should only have the main worktree after removal"
+    );
 }
 
 #[gpui::test]

crates/collab_ui/Cargo.toml 🔗

@@ -24,7 +24,7 @@ test-support = [
     "settings/test-support",
     "util/test-support",
     "workspace/test-support",
-    "http_client/test-support",
+
     "title_bar/test-support",
 ]
 
@@ -67,11 +67,11 @@ collections = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 notifications = { workspace = true, features = ["test-support"] }
-pretty_assertions.workspace = true
+
 project = { workspace = true, features = ["test-support"] }
 rpc = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
-tree-sitter-md.workspace = true
+
 util = { workspace = true, features = ["test-support"] }
-http_client = { workspace = true, features = ["test-support"] }
+
 workspace = { workspace = true, features = ["test-support"] }

crates/collab_ui/src/collab_panel.rs 🔗

@@ -36,8 +36,8 @@ use ui::{
 };
 use util::{ResultExt, TryFutureExt, maybe};
 use workspace::{
-    CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare,
-    ShareProject, Workspace,
+    CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById,
+    ScreenShare, ShareProject, Workspace,
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotifyResultExt},
 };
@@ -114,6 +114,13 @@ pub fn init(cx: &mut App) {
                 });
             }
         });
+        workspace.register_action(|_, action: &OpenChannelNotesById, window, cx| {
+            let channel_id = client::ChannelId(action.channel_id);
+            let workspace = cx.entity();
+            window.defer(cx, move |window, cx| {
+                ChannelView::open(channel_id, None, workspace, window, cx).detach_and_log_err(cx)
+            });
+        });
         // TODO: make it possible to bind this one to a held key for push to talk?
         // how to make "toggle_on_modifiers_press" contextual?
         workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx));
@@ -2340,9 +2347,7 @@ impl CollabPanel {
                     .gap_2()
                     .child(
                         Button::new("sign_in", button_label)
-                            .icon_color(Color::Muted)
-                            .icon(IconName::Github)
-                            .icon_position(IconPosition::Start)
+                            .start_icon(Icon::new(IconName::Github).color(Color::Muted))
                             .style(ButtonStyle::Filled)
                             .full_width()
                             .disabled(is_signing_in)
@@ -2590,9 +2595,9 @@ impl CollabPanel {
             Section::Channels => {
                 Some(
                     h_flex()
-                        .gap_1()
                         .child(
                             IconButton::new("filter-active-channels", IconName::ListFilter)
+                                .icon_size(IconSize::Small)
                                 .toggle_state(self.filter_active_channels)
                                 .when(!self.filter_active_channels, |button| {
                                     button.visible_on_hover("section-header")

crates/collab_ui/src/notification_panel.rs 🔗

@@ -544,9 +544,7 @@ impl Render for NotificationPanel {
                             .p_4()
                             .child(
                                 Button::new("connect_prompt_button", "Connect")
-                                    .icon_color(Color::Muted)
-                                    .icon(IconName::Github)
-                                    .icon_position(IconPosition::Start)
+                                    .start_icon(Icon::new(IconName::Github).color(Color::Muted))
                                     .style(ButtonStyle::Filled)
                                     .full_width()
                                     .on_click({

crates/command_palette/Cargo.toml 🔗

@@ -38,14 +38,14 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
-ctor.workspace = true
+
 db = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
+
 go_to_line.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 menu.workspace = true
 project = { workspace = true, features = ["test-support"] }
-serde_json.workspace = true
+
 workspace = { workspace = true, features = ["test-support"] }

crates/copilot/Cargo.toml 🔗

@@ -52,14 +52,10 @@ workspace.workspace = true
 async-std = { version = "1.12.0", features = ["unstable"] }
 
 [dev-dependencies]
-client = { workspace = true, features = ["test-support"] }
-clock = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
-ctor.workspace = true
 editor = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
-http_client = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }
 lsp = { workspace = true, features = ["test-support"] }

crates/copilot/src/copilot.rs 🔗

@@ -949,7 +949,7 @@ impl Copilot {
             && let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id())
         {
             match event {
-                language::BufferEvent::Edited => {
+                language::BufferEvent::Edited { .. } => {
                     drop(registered_buffer.report_changes(&buffer, cx));
                 }
                 language::BufferEvent::Saved => {
@@ -1779,6 +1779,7 @@ mod tests {
         fn disk_state(&self) -> language::DiskState {
             language::DiskState::Present {
                 mtime: ::fs::MTime::from_seconds_and_nanos(100, 42),
+                size: 0,
             }
         }
 

crates/copilot_chat/Cargo.toml 🔗

@@ -21,6 +21,7 @@ test-support = [
 ]
 
 [dependencies]
+anthropic.workspace = true
 anyhow.workspace = true
 collections.workspace = true
 dirs.workspace = true

crates/copilot_chat/src/copilot_chat.rs 🔗

@@ -52,6 +52,10 @@ impl CopilotChatConfiguration {
         format!("{}/responses", api_endpoint)
     }
 
+    pub fn messages_url(&self, api_endpoint: &str) -> String {
+        format!("{}/v1/messages", api_endpoint)
+    }
+
     pub fn models_url(&self, api_endpoint: &str) -> String {
         format!("{}/models", api_endpoint)
     }
@@ -77,6 +81,30 @@ pub enum Role {
     System,
 }
 
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum ChatLocation {
+    #[default]
+    Panel,
+    Editor,
+    EditingSession,
+    Terminal,
+    Agent,
+    Other,
+}
+
+impl ChatLocation {
+    pub fn to_intent_string(self) -> &'static str {
+        match self {
+            ChatLocation::Panel => "conversation-panel",
+            ChatLocation::Editor => "conversation-inline",
+            ChatLocation::EditingSession => "conversation-edits",
+            ChatLocation::Terminal => "conversation-terminal",
+            ChatLocation::Agent => "conversation-agent",
+            ChatLocation::Other => "conversation-other",
+        }
+    }
+}
+
 #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
 pub enum ModelSupportedEndpoint {
     #[serde(rename = "/chat/completions")]
@@ -179,6 +207,16 @@ struct ModelSupportedFeatures {
     parallel_tool_calls: bool,
     #[serde(default)]
     vision: bool,
+    #[serde(default)]
+    thinking: bool,
+    #[serde(default)]
+    adaptive_thinking: bool,
+    #[serde(default)]
+    max_thinking_budget: Option<u32>,
+    #[serde(default)]
+    min_thinking_budget: Option<u32>,
+    #[serde(default)]
+    reasoning_effort: Vec<String>,
 }
 
 #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
@@ -226,6 +264,10 @@ impl Model {
         self.capabilities.limits.max_context_window_tokens as u64
     }
 
+    pub fn max_output_tokens(&self) -> usize {
+        self.capabilities.limits.max_output_tokens
+    }
+
     pub fn supports_tools(&self) -> bool {
         self.capabilities.supports.tool_calls
     }
@@ -256,6 +298,41 @@ impl Model {
                 .contains(&ModelSupportedEndpoint::Responses)
     }
 
+    pub fn supports_messages(&self) -> bool {
+        self.supported_endpoints
+            .contains(&ModelSupportedEndpoint::Messages)
+    }
+
+    pub fn supports_thinking(&self) -> bool {
+        self.capabilities.supports.thinking
+    }
+
+    pub fn supports_adaptive_thinking(&self) -> bool {
+        self.capabilities.supports.adaptive_thinking
+    }
+
+    pub fn can_think(&self) -> bool {
+        self.supports_thinking()
+            || self.supports_adaptive_thinking()
+            || self.max_thinking_budget().is_some()
+    }
+
+    pub fn max_thinking_budget(&self) -> Option<u32> {
+        self.capabilities.supports.max_thinking_budget
+    }
+
+    pub fn min_thinking_budget(&self) -> Option<u32> {
+        self.capabilities.supports.min_thinking_budget
+    }
+
+    pub fn reasoning_effort_levels(&self) -> &[String] {
+        &self.capabilities.supports.reasoning_effort
+    }
+
+    pub fn family(&self) -> &str {
+        &self.capabilities.family
+    }
+
     pub fn multiplier(&self) -> f64 {
         self.billing.multiplier
     }
@@ -263,7 +340,6 @@ impl Model {
 
 #[derive(Serialize, Deserialize)]
 pub struct Request {
-    pub intent: bool,
     pub n: usize,
     pub stream: bool,
     pub temperature: f32,
@@ -273,6 +349,8 @@ pub struct Request {
     pub tools: Vec<Tool>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub tool_choice: Option<ToolChoice>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub thinking_budget: Option<u32>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -550,6 +628,7 @@ impl CopilotChat {
 
     pub async fn stream_completion(
         request: Request,
+        location: ChatLocation,
         is_user_initiated: bool,
         mut cx: AsyncApp,
     ) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
@@ -563,12 +642,14 @@ impl CopilotChat {
             api_url.into(),
             request,
             is_user_initiated,
+            location,
         )
         .await
     }
 
     pub async fn stream_response(
         request: responses::Request,
+        location: ChatLocation,
         is_user_initiated: bool,
         mut cx: AsyncApp,
     ) -> Result<BoxStream<'static, Result<responses::StreamEvent>>> {
@@ -582,6 +663,30 @@ impl CopilotChat {
             api_url,
             request,
             is_user_initiated,
+            location,
+        )
+        .await
+    }
+
+    pub async fn stream_messages(
+        body: String,
+        location: ChatLocation,
+        is_user_initiated: bool,
+        anthropic_beta: Option<String>,
+        mut cx: AsyncApp,
+    ) -> Result<BoxStream<'static, Result<anthropic::Event, anthropic::AnthropicError>>> {
+        let (client, oauth_token, api_endpoint, configuration) =
+            Self::get_auth_details(&mut cx).await?;
+
+        let api_url = configuration.messages_url(&api_endpoint);
+        stream_messages(
+            client.clone(),
+            oauth_token,
+            api_url,
+            body,
+            is_user_initiated,
+            location,
+            anthropic_beta,
         )
         .await
     }
@@ -755,6 +860,7 @@ pub(crate) fn copilot_request_headers(
     builder: http_client::Builder,
     oauth_token: &str,
     is_user_initiated: Option<bool>,
+    location: Option<ChatLocation>,
 ) -> http_client::Builder {
     builder
         .header("Authorization", format!("Bearer {}", oauth_token))
@@ -766,12 +872,19 @@ pub(crate) fn copilot_request_headers(
                 option_env!("CARGO_PKG_VERSION").unwrap_or("unknown")
             ),
         )
+        .header("X-GitHub-Api-Version", "2025-10-01")
         .when_some(is_user_initiated, |builder, is_user_initiated| {
             builder.header(
                 "X-Initiator",
                 if is_user_initiated { "user" } else { "agent" },
             )
         })
+        .when_some(location, |builder, loc| {
+            let interaction_type = loc.to_intent_string();
+            builder
+                .header("X-Interaction-Type", interaction_type)
+                .header("OpenAI-Intent", interaction_type)
+        })
 }
 
 async fn request_models(
@@ -785,8 +898,8 @@ async fn request_models(
             .uri(models_url.as_ref()),
         &oauth_token,
         None,
-    )
-    .header("x-github-api-version", "2025-05-01");
+        None,
+    );
 
     let request = request_builder.body(AsyncBody::empty())?;
 
@@ -830,6 +943,7 @@ async fn stream_completion(
     completion_url: Arc<str>,
     request: Request,
     is_user_initiated: bool,
+    location: ChatLocation,
 ) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
     let is_vision_request = request.messages.iter().any(|message| match message {
         ChatMessage::User { content }
@@ -846,6 +960,7 @@ async fn stream_completion(
             .uri(completion_url.as_ref()),
         &oauth_token,
         Some(is_user_initiated),
+        Some(location),
     )
     .when(is_vision_request, |builder| {
         builder.header("Copilot-Vision-Request", is_vision_request.to_string())
@@ -905,6 +1020,65 @@ async fn stream_completion(
     }
 }
 
+async fn stream_messages(
+    client: Arc<dyn HttpClient>,
+    oauth_token: String,
+    api_url: String,
+    body: String,
+    is_user_initiated: bool,
+    location: ChatLocation,
+    anthropic_beta: Option<String>,
+) -> Result<BoxStream<'static, Result<anthropic::Event, anthropic::AnthropicError>>> {
+    let mut request_builder = copilot_request_headers(
+        HttpRequest::builder().method(Method::POST).uri(&api_url),
+        &oauth_token,
+        Some(is_user_initiated),
+        Some(location),
+    );
+
+    if let Some(beta) = &anthropic_beta {
+        request_builder = request_builder.header("anthropic-beta", beta.as_str());
+    }
+
+    let request = request_builder.body(AsyncBody::from(body))?;
+    let mut response = client.send(request).await?;
+
+    if !response.status().is_success() {
+        let mut body = String::new();
+        response.body_mut().read_to_string(&mut body).await?;
+        anyhow::bail!("Failed to connect to API: {} {}", response.status(), body);
+    }
+
+    let reader = BufReader::new(response.into_body());
+    Ok(reader
+        .lines()
+        .filter_map(|line| async move {
+            match line {
+                Ok(line) => {
+                    let line = line
+                        .strip_prefix("data: ")
+                        .or_else(|| line.strip_prefix("data:"))?;
+                    if line.starts_with("[DONE]") || line.is_empty() {
+                        return None;
+                    }
+                    match serde_json::from_str(line) {
+                        Ok(event) => Some(Ok(event)),
+                        Err(error) => {
+                            log::error!(
+                                "Failed to parse Copilot messages stream event: `{}`\nResponse: `{}`",
+                                error,
+                                line,
+                            );
+                            Some(Err(anthropic::AnthropicError::DeserializeResponse(error)))
+                        }
+                    }
+                }
+                Err(error) => Some(Err(anthropic::AnthropicError::ReadResponse(error))),
+            }
+        })
+        .boxed())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1513,6 +1687,11 @@ mod tests {
                     tool_calls: true,
                     parallel_tool_calls: false,
                     vision: false,
+                    thinking: false,
+                    adaptive_thinking: false,
+                    max_thinking_budget: None,
+                    min_thinking_budget: None,
+                    reasoning_effort: vec![],
                 },
                 model_type: "chat".to_string(),
                 tokenizer: None,

crates/copilot_chat/src/responses.rs 🔗

@@ -1,9 +1,9 @@
 use std::sync::Arc;
 
-use super::copilot_request_headers;
+use super::{ChatLocation, copilot_request_headers};
 use anyhow::{Result, anyhow};
 use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
-use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
+use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request as HttpRequest};
 use serde::{Deserialize, Serialize};
 use serde_json::Value;
 pub use settings::OpenAiReasoningEffort as ReasoningEffort;
@@ -24,6 +24,7 @@ pub struct Request {
     pub reasoning: Option<ReasoningConfig>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub include: Option<Vec<ResponseIncludable>>,
+    pub store: bool,
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone)]
@@ -280,6 +281,7 @@ pub async fn stream_response(
     api_url: String,
     request: Request,
     is_user_initiated: bool,
+    location: ChatLocation,
 ) -> Result<BoxStream<'static, Result<StreamEvent>>> {
     let is_vision_request = request.input.iter().any(|item| match item {
         ResponseInputItem::Message {
@@ -295,13 +297,11 @@ pub async fn stream_response(
         HttpRequest::builder().method(Method::POST).uri(&api_url),
         &oauth_token,
         Some(is_user_initiated),
-    );
-
-    let request_builder = if is_vision_request {
-        request_builder.header("Copilot-Vision-Request", "true")
-    } else {
-        request_builder
-    };
+        Some(location),
+    )
+    .when(is_vision_request, |builder| {
+        builder.header("Copilot-Vision-Request", "true")
+    });
 
     let is_streaming = request.stream;
     let json = serde_json::to_string(&request)?;

crates/copilot_ui/src/sign_in.rs 🔗

@@ -387,10 +387,11 @@ impl CopilotCodeVerification {
                     .full_width()
                     .style(ButtonStyle::Outlined)
                     .size(ButtonSize::Medium)
-                    .icon(IconName::Download)
-                    .icon_color(Color::Muted)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .on_click(move |_, window, cx| {
                         reinstall_and_sign_in(copilot.clone(), window, cx)
                     }),
@@ -570,10 +571,11 @@ impl ConfigurationView {
                 }
             })
             .style(ButtonStyle::Outlined)
-            .icon(IconName::Github)
-            .icon_color(Color::Muted)
-            .icon_position(IconPosition::Start)
-            .icon_size(IconSize::Small)
+            .start_icon(
+                Icon::new(IconName::Github)
+                    .size(IconSize::Small)
+                    .color(Color::Muted),
+            )
             .when(edit_prediction, |this| this.tab_index(0isize))
             .on_click(|_, window, cx| {
                 if let Some(app_state) = AppState::global(cx).upgrade()
@@ -600,10 +602,11 @@ impl ConfigurationView {
                 }
             })
             .style(ButtonStyle::Outlined)
-            .icon(IconName::Download)
-            .icon_color(Color::Muted)
-            .icon_position(IconPosition::Start)
-            .icon_size(IconSize::Small)
+            .start_icon(
+                Icon::new(IconName::Download)
+                    .size(IconSize::Small)
+                    .color(Color::Muted),
+            )
             .on_click(|_, window, cx| {
                 if let Some(app_state) = AppState::global(cx).upgrade()
                     && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx)

crates/crashes/src/crashes.rs 🔗

@@ -1,7 +1,7 @@
 use crash_handler::{CrashEventResult, CrashHandler};
 use futures::future::BoxFuture;
 use log::info;
-use minidumper::{Client, LoopAction, MinidumpBinary};
+use minidumper::{Client, LoopAction, MinidumpBinary, Server, SocketName};
 use parking_lot::Mutex;
 use release_channel::{RELEASE_CHANNEL, ReleaseChannel};
 use serde::{Deserialize, Serialize};
@@ -128,7 +128,7 @@ async fn connect_and_keepalive(crash_init: InitCrashHandler, handler: CrashHandl
     let retry_frequency = Duration::from_millis(100);
     let mut maybe_client = None;
     while maybe_client.is_none() {
-        if let Ok(client) = Client::with_name(socket_name.as_path()) {
+        if let Ok(client) = Client::with_name(SocketName::Path(&socket_name)) {
             maybe_client = Some(client);
             info!("connected to crash handler process after {elapsed:?}");
             break;
@@ -350,8 +350,34 @@ impl minidumper::ServerHandler for CrashServer {
     }
 }
 
+/// Rust's string-slicing panics embed the user's string content in the message,
+/// e.g. "byte index 4 is out of bounds of `a`". Strip that suffix so we
+/// don't upload arbitrary user text in crash reports.
+fn strip_user_string_from_panic(message: &str) -> String {
+    const STRING_PANIC_PREFIXES: &[&str] = &[
+        // Older rustc (pre-1.95):
+        "byte index ",
+        "begin <= end (",
+        // Newer rustc (1.95+):
+        // https://github.com/rust-lang/rust/pull/145024
+        "start byte index ",
+        "end byte index ",
+        "begin > end (",
+    ];
+
+    if (message.ends_with('`') || message.ends_with("`[...]"))
+        && STRING_PANIC_PREFIXES
+            .iter()
+            .any(|prefix| message.starts_with(prefix))
+        && let Some(open) = message.find('`')
+    {
+        return format!("{} `<redacted>`", &message[..open]);
+    }
+    message.to_owned()
+}
+
 pub fn panic_hook(info: &PanicHookInfo) {
-    let message = info.payload_as_str().unwrap_or("Box<Any>").to_owned();
+    let message = strip_user_string_from_panic(info.payload_as_str().unwrap_or("Box<Any>"));
 
     let span = info
         .location()
@@ -446,7 +472,7 @@ fn spawn_crash_handler_windows(exe: &Path, socket_name: &Path) {
 }
 
 pub fn crash_server(socket: &Path) {
-    let Ok(mut server) = minidumper::Server::with_name(socket) else {
+    let Ok(mut server) = Server::with_name(SocketName::Path(socket)) else {
         log::info!("Couldn't create socket, there may already be a running crash server");
         return;
     };

crates/dap/Cargo.toml 🔗

@@ -58,7 +58,6 @@ async-pipe.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 task = { workspace = true, features = ["test-support"] }
-tree-sitter.workspace = true
-tree-sitter-go.workspace = true
+
 util = { workspace = true, features = ["test-support"] }
 zlog.workspace = true

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -1821,20 +1821,22 @@ impl Render for DebugPanel {
                         .gap_2()
                         .child(
                             Button::new("spawn-new-session-empty-state", "New Session")
-                                .icon(IconName::Plus)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(
+                                    Icon::new(IconName::Plus)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, window, cx| {
                                     window.dispatch_action(crate::Start.boxed_clone(), cx);
                                 }),
                         )
                         .child(
                             Button::new("edit-debug-settings", "Edit debug.json")
-                                .icon(IconName::Code)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(
+                                    Icon::new(IconName::Code)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, window, cx| {
                                     window.dispatch_action(
                                         zed_actions::OpenProjectDebugTasks.boxed_clone(),
@@ -1844,10 +1846,11 @@ impl Render for DebugPanel {
                         )
                         .child(
                             Button::new("open-debugger-docs", "Debugger Docs")
-                                .icon(IconName::Book)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(
+                                    Icon::new(IconName::Book)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, _, cx| cx.open_url("https://zed.dev/docs/debugger")),
                         )
                         .child(
@@ -1855,10 +1858,11 @@ impl Render for DebugPanel {
                                 "spawn-new-session-install-extensions",
                                 "Debugger Extensions",
                             )
-                            .icon(IconName::Blocks)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .icon_position(IconPosition::Start)
+                            .start_icon(
+                                Icon::new(IconName::Blocks)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .on_click(|_, window, cx| {
                                 window.dispatch_action(
                                     zed_actions::Extensions {

crates/debugger_ui/src/persistence.rs 🔗

@@ -265,49 +265,72 @@ pub(crate) fn deserialize_pane_layout(
                 pane.entity_id(),
                 cx.subscribe_in(&pane, window, RunningState::handle_pane_event),
             );
+            let running_state = cx.weak_entity();
+            let pane_handle = pane.downgrade();
 
             let sub_views: Vec<_> = serialized_pane
                 .children
                 .iter()
                 .map(|child| match child {
-                    DebuggerPaneItem::Frames => {
-                        Box::new(SubView::stack_frame_list(stack_frame_list.clone(), cx))
-                    }
+                    DebuggerPaneItem::Frames => Box::new(SubView::stack_frame_list(
+                        stack_frame_list.clone(),
+                        running_state.clone(),
+                        pane_handle.clone(),
+                        cx,
+                    )),
                     DebuggerPaneItem::Variables => Box::new(SubView::new(
                         variable_list.focus_handle(cx),
                         variable_list.clone().into(),
                         DebuggerPaneItem::Variables,
+                        running_state.clone(),
+                        pane_handle.clone(),
+                        cx,
+                    )),
+                    DebuggerPaneItem::BreakpointList => Box::new(SubView::breakpoint_list(
+                        breakpoint_list.clone(),
+                        running_state.clone(),
+                        pane_handle.clone(),
                         cx,
                     )),
-                    DebuggerPaneItem::BreakpointList => {
-                        Box::new(SubView::breakpoint_list(breakpoint_list.clone(), cx))
-                    }
                     DebuggerPaneItem::Modules => Box::new(SubView::new(
                         module_list.focus_handle(cx),
                         module_list.clone().into(),
                         DebuggerPaneItem::Modules,
+                        running_state.clone(),
+                        pane_handle.clone(),
                         cx,
                     )),
                     DebuggerPaneItem::LoadedSources => Box::new(SubView::new(
                         loaded_sources.focus_handle(cx),
                         loaded_sources.clone().into(),
                         DebuggerPaneItem::LoadedSources,
+                        running_state.clone(),
+                        pane_handle.clone(),
                         cx,
                     )),
                     DebuggerPaneItem::Console => {
-                        let view = SubView::console(console.clone(), cx);
+                        let view = SubView::console(
+                            console.clone(),
+                            running_state.clone(),
+                            pane_handle.clone(),
+                            cx,
+                        );
                         Box::new(view)
                     }
                     DebuggerPaneItem::Terminal => Box::new(SubView::new(
                         terminal.focus_handle(cx),
                         terminal.clone().into(),
                         DebuggerPaneItem::Terminal,
+                        running_state.clone(),
+                        pane_handle.clone(),
                         cx,
                     )),
                     DebuggerPaneItem::MemoryView => Box::new(SubView::new(
                         memory_view.focus_handle(cx),
                         memory_view.clone().into(),
                         DebuggerPaneItem::MemoryView,
+                        running_state.clone(),
+                        pane_handle.clone(),
                         cx,
                     )),
                 })

crates/debugger_ui/src/session/running.rs 🔗

@@ -7,7 +7,6 @@ pub mod stack_frame_list;
 pub mod variable_list;
 use std::{
     any::Any,
-    ops::ControlFlow,
     path::PathBuf,
     sync::{Arc, LazyLock},
     time::Duration,
@@ -72,6 +71,7 @@ pub struct RunningState {
     focus_handle: FocusHandle,
     _remote_id: Option<ViewId>,
     workspace: WeakEntity<Workspace>,
+    project: WeakEntity<Project>,
     session_id: SessionId,
     variable_list: Entity<variable_list::VariableList>,
     _subscriptions: Vec<Subscription>,
@@ -144,6 +144,8 @@ pub(crate) struct SubView {
     inner: AnyView,
     item_focus_handle: FocusHandle,
     kind: DebuggerPaneItem,
+    running_state: WeakEntity<RunningState>,
+    host_pane: WeakEntity<Pane>,
     show_indicator: Box<dyn Fn(&App) -> bool>,
     actions: Option<Box<dyn FnMut(&mut Window, &mut App) -> AnyElement>>,
     hovered: bool,
@@ -154,12 +156,16 @@ impl SubView {
         item_focus_handle: FocusHandle,
         view: AnyView,
         kind: DebuggerPaneItem,
+        running_state: WeakEntity<RunningState>,
+        host_pane: WeakEntity<Pane>,
         cx: &mut App,
     ) -> Entity<Self> {
         cx.new(|_| Self {
             kind,
             inner: view,
             item_focus_handle,
+            running_state,
+            host_pane,
             show_indicator: Box::new(|_| false),
             actions: None,
             hovered: false,
@@ -168,6 +174,8 @@ impl SubView {
 
     pub(crate) fn stack_frame_list(
         stack_frame_list: Entity<StackFrameList>,
+        running_state: WeakEntity<RunningState>,
+        host_pane: WeakEntity<Pane>,
         cx: &mut App,
     ) -> Entity<Self> {
         let weak_list = stack_frame_list.downgrade();
@@ -175,6 +183,8 @@ impl SubView {
             stack_frame_list.focus_handle(cx),
             stack_frame_list.into(),
             DebuggerPaneItem::Frames,
+            running_state,
+            host_pane,
             cx,
         );
 
@@ -189,12 +199,19 @@ impl SubView {
         this
     }
 
-    pub(crate) fn console(console: Entity<Console>, cx: &mut App) -> Entity<Self> {
+    pub(crate) fn console(
+        console: Entity<Console>,
+        running_state: WeakEntity<RunningState>,
+        host_pane: WeakEntity<Pane>,
+        cx: &mut App,
+    ) -> Entity<Self> {
         let weak_console = console.downgrade();
         let this = Self::new(
             console.focus_handle(cx),
             console.into(),
             DebuggerPaneItem::Console,
+            running_state,
+            host_pane,
             cx,
         );
         this.update(cx, |this, _| {
@@ -207,13 +224,20 @@ impl SubView {
         this
     }
 
-    pub(crate) fn breakpoint_list(list: Entity<BreakpointList>, cx: &mut App) -> Entity<Self> {
+    pub(crate) fn breakpoint_list(
+        list: Entity<BreakpointList>,
+        running_state: WeakEntity<RunningState>,
+        host_pane: WeakEntity<Pane>,
+        cx: &mut App,
+    ) -> Entity<Self> {
         let weak_list = list.downgrade();
         let focus_handle = list.focus_handle(cx);
         let this = Self::new(
             focus_handle,
             list.into(),
             DebuggerPaneItem::BreakpointList,
+            running_state,
+            host_pane,
             cx,
         );
 
@@ -239,6 +263,10 @@ impl SubView {
     ) {
         self.actions = Some(actions);
     }
+
+    fn set_host_pane(&mut self, host_pane: WeakEntity<Pane>) {
+        self.host_pane = host_pane;
+    }
 }
 impl Focusable for SubView {
     fn focus_handle(&self, _: &App) -> FocusHandle {
@@ -281,6 +309,75 @@ impl Item for SubView {
 
         label.into_any_element()
     }
+
+    fn handle_drop(
+        &self,
+        active_pane: &Pane,
+        dropped: &dyn Any,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> bool {
+        let Some(tab) = dropped.downcast_ref::<DraggedTab>() else {
+            return true;
+        };
+        let Some(this_pane) = self.host_pane.upgrade() else {
+            return true;
+        };
+        let item = if tab.pane == this_pane {
+            active_pane.item_for_index(tab.ix)
+        } else {
+            tab.pane.read(cx).item_for_index(tab.ix)
+        };
+        let Some(item) = item.filter(|item| item.downcast::<SubView>().is_some()) else {
+            return true;
+        };
+        let Some(split_direction) = active_pane.drag_split_direction() else {
+            return false;
+        };
+
+        let source = tab.pane.clone();
+        let item_id_to_move = item.item_id();
+        let weak_running = self.running_state.clone();
+
+        // Source pane may be the one currently updated, so defer the move.
+        window.defer(cx, move |window, cx| {
+            let new_pane = weak_running.update(cx, |running, cx| {
+                let Some(project) = running.project.upgrade() else {
+                    return Err(anyhow!("Debugger project has been dropped"));
+                };
+
+                let new_pane = new_debugger_pane(running.workspace.clone(), project, window, cx);
+                let _previous_subscription = running.pane_close_subscriptions.insert(
+                    new_pane.entity_id(),
+                    cx.subscribe_in(&new_pane, window, RunningState::handle_pane_event),
+                );
+                debug_assert!(_previous_subscription.is_none());
+                running
+                    .panes
+                    .split(&this_pane, &new_pane, split_direction, cx);
+                anyhow::Ok(new_pane)
+            });
+
+            match new_pane.and_then(|result| result) {
+                Ok(new_pane) => {
+                    move_item(
+                        &source,
+                        &new_pane,
+                        item_id_to_move,
+                        new_pane.read(cx).active_item_index(),
+                        true,
+                        window,
+                        cx,
+                    );
+                }
+                Err(err) => {
+                    log::error!("{err:?}");
+                }
+            }
+        });
+
+        true
+    }
 }
 
 impl Render for SubView {
@@ -311,83 +408,18 @@ pub(crate) fn new_debugger_pane(
     cx: &mut Context<RunningState>,
 ) -> Entity<Pane> {
     let weak_running = cx.weak_entity();
-    let custom_drop_handle = {
-        let workspace = workspace.clone();
-        let project = project.downgrade();
-        let weak_running = weak_running.clone();
-        move |pane: &mut Pane, any: &dyn Any, window: &mut Window, cx: &mut Context<Pane>| {
-            let Some(tab) = any.downcast_ref::<DraggedTab>() else {
-                return ControlFlow::Break(());
-            };
-            let Some(project) = project.upgrade() else {
-                return ControlFlow::Break(());
-            };
-            let this_pane = cx.entity();
-            let item = if tab.pane == this_pane {
-                pane.item_for_index(tab.ix)
-            } else {
-                tab.pane.read(cx).item_for_index(tab.ix)
-            };
-            let Some(item) = item.filter(|item| item.downcast::<SubView>().is_some()) else {
-                return ControlFlow::Break(());
-            };
-
-            let source = tab.pane.clone();
-            let item_id_to_move = item.item_id();
-
-            let Some(split_direction) = pane.drag_split_direction() else {
-                // If we drop into existing pane or current pane,
-                // regular pane drop handler will take care of it,
-                // using the right tab index for the operation.
-                return ControlFlow::Continue(());
-            };
-
-            let workspace = workspace.clone();
-            let weak_running = weak_running.clone();
-            // Source pane may be the one currently updated, so defer the move.
-            window.defer(cx, move |window, cx| {
-                let new_pane = weak_running.update(cx, |running, cx| {
-                    let new_pane =
-                        new_debugger_pane(workspace.clone(), project.clone(), window, cx);
-                    let _previous_subscription = running.pane_close_subscriptions.insert(
-                        new_pane.entity_id(),
-                        cx.subscribe_in(&new_pane, window, RunningState::handle_pane_event),
-                    );
-                    debug_assert!(_previous_subscription.is_none());
-                    running
-                        .panes
-                        .split(&this_pane, &new_pane, split_direction, cx);
-                    new_pane
-                });
-
-                match new_pane {
-                    Ok(new_pane) => {
-                        move_item(
-                            &source,
-                            &new_pane,
-                            item_id_to_move,
-                            new_pane.read(cx).active_item_index(),
-                            true,
-                            window,
-                            cx,
-                        );
-                    }
-                    Err(err) => {
-                        log::error!("{err:?}");
-                    }
-                };
-            });
-
-            ControlFlow::Break(())
-        }
-    };
 
     cx.new(move |cx| {
+        let can_drop_predicate: Arc<dyn Fn(&dyn Any, &mut Window, &mut App) -> bool> =
+            Arc::new(|any, _window, _cx| {
+                any.downcast_ref::<DraggedTab>()
+                    .is_some_and(|dragged_tab| dragged_tab.item.downcast::<SubView>().is_some())
+            });
         let mut pane = Pane::new(
             workspace.clone(),
             project.clone(),
             Default::default(),
-            None,
+            Some(can_drop_predicate),
             NoAction.boxed_clone(),
             true,
             window,
@@ -426,7 +458,6 @@ pub(crate) fn new_debugger_pane(
         })));
         pane.set_can_toggle_zoom(false, cx);
         pane.display_nav_history_buttons(None);
-        pane.set_custom_drop_handle(cx, custom_drop_handle);
         pane.set_should_display_tab_bar(|_, _| true);
         pane.set_render_tab_bar_buttons(cx, |_, _, _| (None, None));
         pane.set_render_tab_bar(cx, {
@@ -466,8 +497,17 @@ pub(crate) fn new_debugger_pane(
                             })
                             .on_drop(cx.listener(
                                 move |this, dragged_tab: &DraggedTab, window, cx| {
+                                    if dragged_tab.item.downcast::<SubView>().is_none() {
+                                        return;
+                                    }
                                     this.drag_split_direction = None;
-                                    this.handle_tab_drop(dragged_tab, this.items_len(), window, cx)
+                                    this.handle_tab_drop(
+                                        dragged_tab,
+                                        this.items_len(),
+                                        false,
+                                        window,
+                                        cx,
+                                    )
                                 },
                             ))
                             .children(pane.items().enumerate().map(|(ix, item)| {
@@ -516,8 +556,11 @@ pub(crate) fn new_debugger_pane(
                                     ))
                                     .on_drop(cx.listener(
                                         move |this, dragged_tab: &DraggedTab, window, cx| {
+                                            if dragged_tab.item.downcast::<SubView>().is_none() {
+                                                return;
+                                            }
                                             this.drag_split_direction = None;
-                                            this.handle_tab_drop(dragged_tab, ix, window, cx)
+                                            this.handle_tab_drop(dragged_tab, ix, false, window, cx)
                                         },
                                     ))
                                     .on_drag(
@@ -729,6 +772,7 @@ impl RunningState {
     ) -> Self {
         let focus_handle = cx.focus_handle();
         let session_id = session.read(cx).session_id();
+        let weak_project = project.downgrade();
         let weak_state = cx.weak_entity();
         let stack_frame_list = cx.new(|cx| {
             StackFrameList::new(
@@ -904,6 +948,7 @@ impl RunningState {
             memory_view,
             session,
             workspace,
+            project: weak_project,
             focus_handle,
             variable_list,
             _subscriptions,
@@ -1304,48 +1349,71 @@ impl RunningState {
     fn create_sub_view(
         &self,
         item_kind: DebuggerPaneItem,
-        _pane: &Entity<Pane>,
+        pane: &Entity<Pane>,
         cx: &mut Context<Self>,
     ) -> Box<dyn ItemHandle> {
+        let running_state = cx.weak_entity();
+        let host_pane = pane.downgrade();
+
         match item_kind {
-            DebuggerPaneItem::Console => Box::new(SubView::console(self.console.clone(), cx)),
+            DebuggerPaneItem::Console => Box::new(SubView::console(
+                self.console.clone(),
+                running_state,
+                host_pane,
+                cx,
+            )),
             DebuggerPaneItem::Variables => Box::new(SubView::new(
                 self.variable_list.focus_handle(cx),
                 self.variable_list.clone().into(),
                 item_kind,
+                running_state,
+                host_pane,
+                cx,
+            )),
+            DebuggerPaneItem::BreakpointList => Box::new(SubView::breakpoint_list(
+                self.breakpoint_list.clone(),
+                running_state,
+                host_pane,
                 cx,
             )),
-            DebuggerPaneItem::BreakpointList => {
-                Box::new(SubView::breakpoint_list(self.breakpoint_list.clone(), cx))
-            }
             DebuggerPaneItem::Frames => Box::new(SubView::new(
                 self.stack_frame_list.focus_handle(cx),
                 self.stack_frame_list.clone().into(),
                 item_kind,
+                running_state,
+                host_pane,
                 cx,
             )),
             DebuggerPaneItem::Modules => Box::new(SubView::new(
                 self.module_list.focus_handle(cx),
                 self.module_list.clone().into(),
                 item_kind,
+                running_state,
+                host_pane,
                 cx,
             )),
             DebuggerPaneItem::LoadedSources => Box::new(SubView::new(
                 self.loaded_sources_list.focus_handle(cx),
                 self.loaded_sources_list.clone().into(),
                 item_kind,
+                running_state,
+                host_pane,
                 cx,
             )),
             DebuggerPaneItem::Terminal => Box::new(SubView::new(
                 self.debug_terminal.focus_handle(cx),
                 self.debug_terminal.clone().into(),
                 item_kind,
+                running_state,
+                host_pane,
                 cx,
             )),
             DebuggerPaneItem::MemoryView => Box::new(SubView::new(
                 self.memory_view.focus_handle(cx),
                 self.memory_view.clone().into(),
                 item_kind,
+                running_state,
+                host_pane,
                 cx,
             )),
         }
@@ -1454,6 +1522,13 @@ impl RunningState {
     ) {
         this.serialize_layout(window, cx);
         match event {
+            Event::AddItem { item } => {
+                if let Some(sub_view) = item.downcast::<SubView>() {
+                    sub_view.update(cx, |sub_view, _| {
+                        sub_view.set_host_pane(source_pane.downgrade());
+                    });
+                }
+            }
             Event::Remove { .. } => {
                 let _did_find_pane = this.panes.remove(source_pane, cx).is_ok();
                 debug_assert!(_did_find_pane);
@@ -1795,23 +1870,28 @@ impl RunningState {
         window: &mut Window,
         cx: &mut Context<'_, RunningState>,
     ) -> Member {
+        let running_state = cx.weak_entity();
+
         let leftmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx);
+        let leftmost_pane_handle = leftmost_pane.downgrade();
+        let leftmost_frames = SubView::new(
+            stack_frame_list.focus_handle(cx),
+            stack_frame_list.clone().into(),
+            DebuggerPaneItem::Frames,
+            running_state.clone(),
+            leftmost_pane_handle.clone(),
+            cx,
+        );
+        let leftmost_breakpoints = SubView::breakpoint_list(
+            breakpoints.clone(),
+            running_state.clone(),
+            leftmost_pane_handle,
+            cx,
+        );
         leftmost_pane.update(cx, |this, cx| {
+            this.add_item(Box::new(leftmost_frames), true, false, None, window, cx);
             this.add_item(
-                Box::new(SubView::new(
-                    this.focus_handle(cx),
-                    stack_frame_list.clone().into(),
-                    DebuggerPaneItem::Frames,
-                    cx,
-                )),
-                true,
-                false,
-                None,
-                window,
-                cx,
-            );
-            this.add_item(
-                Box::new(SubView::breakpoint_list(breakpoints.clone(), cx)),
+                Box::new(leftmost_breakpoints),
                 true,
                 false,
                 None,
@@ -1820,44 +1900,42 @@ impl RunningState {
             );
             this.activate_item(0, false, false, window, cx);
         });
+
         let center_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx);
+        let center_pane_handle = center_pane.downgrade();
+        let center_console = SubView::console(
+            console.clone(),
+            running_state.clone(),
+            center_pane_handle.clone(),
+            cx,
+        );
+        let center_variables = SubView::new(
+            variable_list.focus_handle(cx),
+            variable_list.clone().into(),
+            DebuggerPaneItem::Variables,
+            running_state.clone(),
+            center_pane_handle,
+            cx,
+        );
 
         center_pane.update(cx, |this, cx| {
-            let view = SubView::console(console.clone(), cx);
+            this.add_item(Box::new(center_console), true, false, None, window, cx);
 
-            this.add_item(Box::new(view), true, false, None, window, cx);
-
-            this.add_item(
-                Box::new(SubView::new(
-                    variable_list.focus_handle(cx),
-                    variable_list.clone().into(),
-                    DebuggerPaneItem::Variables,
-                    cx,
-                )),
-                true,
-                false,
-                None,
-                window,
-                cx,
-            );
+            this.add_item(Box::new(center_variables), true, false, None, window, cx);
             this.activate_item(0, false, false, window, cx);
         });
 
         let rightmost_pane = new_debugger_pane(workspace.clone(), project, window, cx);
+        let rightmost_terminal = SubView::new(
+            debug_terminal.focus_handle(cx),
+            debug_terminal.clone().into(),
+            DebuggerPaneItem::Terminal,
+            running_state,
+            rightmost_pane.downgrade(),
+            cx,
+        );
         rightmost_pane.update(cx, |this, cx| {
-            this.add_item(
-                Box::new(SubView::new(
-                    debug_terminal.focus_handle(cx),
-                    debug_terminal.clone().into(),
-                    DebuggerPaneItem::Terminal,
-                    cx,
-                )),
-                false,
-                false,
-                None,
-                window,
-                cx,
-            );
+            this.add_item(Box::new(rightmost_terminal), false, false, None, window, cx);
         });
 
         subscriptions.extend(

crates/debugger_ui/src/tests.rs 🔗

@@ -132,7 +132,13 @@ pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
             .workspace()
             .read(cx)
             .panel::<DebugPanel>(cx)
-            .and_then(|panel| panel.read(cx).active_session())
+            .and_then(|panel| {
+                panel
+                    .read(cx)
+                    .sessions_with_children
+                    .keys()
+                    .max_by_key(|session| session.read(cx).session_id(cx))
+            })
             .map(|session| session.read(cx).running_state().read(cx).session())
             .cloned()
             .context("Failed to get active session")

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

@@ -27,7 +27,7 @@ use std::{
     path::Path,
     sync::{
         Arc,
-        atomic::{AtomicBool, Ordering},
+        atomic::{AtomicBool, AtomicUsize, Ordering},
     },
 };
 use terminal_view::terminal_panel::TerminalPanel;
@@ -2481,3 +2481,75 @@ async fn test_adapter_shutdown_with_child_sessions_on_app_quit(
         "Child session should have received disconnect request"
     );
 }
+
+#[gpui::test]
+async fn test_restart_request_is_not_sent_more_than_once_until_response(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "First line\nSecond line\nThird line\nFourth line",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let session = start_debug_session(&workspace, cx, move |client| {
+        client.on_request::<dap::requests::Initialize, _>(move |_, _| {
+            Ok(dap::Capabilities {
+                supports_restart_request: Some(true),
+                ..Default::default()
+            })
+        });
+    })
+    .unwrap();
+
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    let restart_count = Arc::new(AtomicUsize::new(0));
+
+    client.on_request::<dap::requests::Restart, _>({
+        let restart_count = restart_count.clone();
+        move |_, _| {
+            restart_count.fetch_add(1, Ordering::SeqCst);
+            Ok(())
+        }
+    });
+
+    // This works because the restart request sender is on the foreground thread
+    // so it will start running after the gpui update stack is cleared
+    session.update(cx, |session, cx| {
+        session.restart(None, cx);
+        session.restart(None, cx);
+        session.restart(None, cx);
+    });
+
+    cx.run_until_parked();
+
+    assert_eq!(
+        restart_count.load(Ordering::SeqCst),
+        1,
+        "Only one restart request should be sent while a restart is in-flight"
+    );
+
+    session.update(cx, |session, cx| {
+        session.restart(None, cx);
+    });
+
+    cx.run_until_parked();
+
+    assert_eq!(
+        restart_count.load(Ordering::SeqCst),
+        2,
+        "A second restart should be allowed after the first one completes"
+    );
+}

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

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

crates/dev_container/Cargo.toml 🔗

@@ -29,7 +29,7 @@ gpui = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
 settings = { workspace = true, features = ["test-support"] }
-theme.workspace = true
+
 workspace = { workspace = true, features = ["test-support"] }
 worktree = { workspace = true, features = ["test-support"] }
 

crates/diagnostics/Cargo.toml 🔗

@@ -38,7 +38,7 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
-client = { workspace = true, features = ["test-support"] }
+
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }

crates/diagnostics/src/diagnostic_renderer.rs 🔗

@@ -297,7 +297,7 @@ impl DiagnosticBlock {
                     return;
                 };
 
-                for (excerpt_id, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) {
+                for (excerpt_id, _, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) {
                     if range.context.overlaps(&diagnostic.range, &snapshot) {
                         Self::jump_to(
                             editor,

crates/diagnostics/src/diagnostics.rs 🔗

@@ -583,7 +583,7 @@ impl ProjectDiagnosticsEditor {
                         RetainExcerpts::All | RetainExcerpts::Dirty => multi_buffer
                             .excerpts_for_buffer(buffer_id, cx)
                             .into_iter()
-                            .map(|(_, range)| range)
+                            .map(|(_, _, range)| range)
                             .sorted_by(|a, b| cmp_excerpts(&buffer_snapshot, a, b))
                             .collect(),
                     }

crates/diagnostics/src/items.rs 🔗

@@ -28,7 +28,7 @@ pub struct DiagnosticIndicator {
 
 impl Render for DiagnosticIndicator {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let indicator = h_flex().gap_2();
+        let indicator = h_flex().gap_2().min_w_0().overflow_x_hidden();
         if !ProjectSettings::get_global(cx).diagnostics.button {
             return indicator.hidden();
         }
@@ -67,6 +67,7 @@ impl Render for DiagnosticIndicator {
             Some(
                 Button::new("diagnostic_message", SharedString::new(message))
                     .label_size(LabelSize::Small)
+                    .truncate(true)
                     .tooltip(|_window, cx| {
                         Tooltip::for_action(
                             "Next Diagnostic",

crates/edit_prediction/Cargo.toml 🔗

@@ -82,5 +82,5 @@ parking_lot.workspace = true
 project = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
-tree-sitter-rust.workspace = true
+
 zlog.workspace = true

crates/edit_prediction/src/capture_example.rs 🔗

@@ -1,12 +1,9 @@
-use crate::{
-    StoredEvent, cursor_excerpt::editable_and_context_ranges_for_cursor_position,
-    example_spec::ExampleSpec,
-};
+use crate::{StoredEvent, example_spec::ExampleSpec};
 use anyhow::Result;
 use buffer_diff::BufferDiffSnapshot;
 use collections::HashMap;
 use gpui::{App, Entity, Task};
-use language::{Buffer, ToPoint as _};
+use language::Buffer;
 use project::{Project, WorktreeId};
 use std::{collections::hash_map, fmt::Write as _, ops::Range, path::Path, sync::Arc};
 use text::{BufferSnapshot as TextBufferSnapshot, Point};
@@ -157,17 +154,34 @@ fn compute_cursor_excerpt(
     cursor_anchor: language::Anchor,
 ) -> (String, usize, Range<Point>) {
     use text::ToOffset as _;
+    use text::ToPoint as _;
 
-    let cursor_point = cursor_anchor.to_point(snapshot);
-    let (_editable_range, context_range) =
-        editable_and_context_ranges_for_cursor_position(cursor_point, snapshot, 100, 50);
-    let context_start_offset = context_range.start.to_offset(snapshot);
     let cursor_offset = cursor_anchor.to_offset(snapshot);
-    let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset);
-    let excerpt = snapshot
-        .text_for_range(context_range.clone())
-        .collect::<String>();
-    (excerpt, cursor_offset_in_excerpt, context_range)
+    let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
+        crate::cursor_excerpt::compute_cursor_excerpt(snapshot, cursor_offset);
+    let syntax_ranges = crate::cursor_excerpt::compute_syntax_ranges(
+        snapshot,
+        cursor_offset,
+        &excerpt_offset_range,
+    );
+    let excerpt_text: String = snapshot.text_for_range(excerpt_point_range).collect();
+    let (_, context_range) = zeta_prompt::compute_editable_and_context_ranges(
+        &excerpt_text,
+        cursor_offset_in_excerpt,
+        &syntax_ranges,
+        100,
+        50,
+    );
+    let context_text = excerpt_text[context_range.clone()].to_string();
+    let cursor_in_context = cursor_offset_in_excerpt.saturating_sub(context_range.start);
+    let context_buffer_start =
+        (excerpt_offset_range.start + context_range.start).to_point(snapshot);
+    let context_buffer_end = (excerpt_offset_range.start + context_range.end).to_point(snapshot);
+    (
+        context_text,
+        cursor_in_context,
+        context_buffer_start..context_buffer_end,
+    )
 }
 
 async fn collect_snapshots(
@@ -533,8 +547,8 @@ mod tests {
             zlog::init_test();
             let http_client = FakeHttpClient::with_404_response();
             let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx);
-            language_model::init(client.clone(), cx);
             let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
+            language_model::init(user_store.clone(), client.clone(), cx);
             EditPredictionStore::global(&client, &user_store, cx);
         })
     }

crates/edit_prediction/src/cursor_excerpt.rs 🔗

@@ -1,107 +1,140 @@
-use language::{BufferSnapshot, Point};
+use language::{BufferSnapshot, Point, ToPoint as _};
 use std::ops::Range;
 use text::OffsetRangeExt as _;
-use zeta_prompt::ExcerptRanges;
 
-/// Computes all range variants for a cursor position: editable ranges at 150, 180, and 350
-/// token budgets, plus their corresponding context expansions. Returns the full excerpt range
-/// (union of all context ranges) and the individual sub-ranges as Points.
-pub fn compute_excerpt_ranges(
-    position: Point,
+const CURSOR_EXCERPT_TOKEN_BUDGET: usize = 8192;
+
+/// Computes a cursor excerpt as the largest linewise symmetric region around
+/// the cursor that fits within an 8192-token budget. Returns the point range,
+/// byte offset range, and the cursor offset relative to the excerpt start.
+pub fn compute_cursor_excerpt(
     snapshot: &BufferSnapshot,
-) -> (Range<Point>, Range<usize>, ExcerptRanges) {
-    let editable_150 = compute_editable_range(snapshot, position, 150);
-    let editable_180 = compute_editable_range(snapshot, position, 180);
-    let editable_350 = compute_editable_range(snapshot, position, 350);
-    let editable_512 = compute_editable_range(snapshot, position, 512);
-
-    let editable_150_context_350 =
-        expand_context_syntactically_then_linewise(snapshot, editable_150.clone(), 350);
-    let editable_180_context_350 =
-        expand_context_syntactically_then_linewise(snapshot, editable_180.clone(), 350);
-    let editable_350_context_150 =
-        expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 150);
-    let editable_350_context_512 =
-        expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 512);
-    let editable_350_context_1024 =
-        expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 1024);
-    let context_4096 = expand_context_syntactically_then_linewise(
-        snapshot,
-        editable_350_context_1024.clone(),
-        4096 - 1024,
-    );
-    let context_8192 =
-        expand_context_syntactically_then_linewise(snapshot, context_4096.clone(), 8192 - 4096);
-
-    let full_start_row = context_8192.start.row;
-    let full_end_row = context_8192.end.row;
-
-    let full_context =
-        Point::new(full_start_row, 0)..Point::new(full_end_row, snapshot.line_len(full_end_row));
-
-    let full_context_offset_range = full_context.to_offset(snapshot);
-
-    let to_offset = |range: &Range<Point>| -> Range<usize> {
-        let start = range.start.to_offset(snapshot);
-        let end = range.end.to_offset(snapshot);
-        (start - full_context_offset_range.start)..(end - full_context_offset_range.start)
-    };
-
-    let ranges = ExcerptRanges {
-        editable_150: to_offset(&editable_150),
-        editable_180: to_offset(&editable_180),
-        editable_350: to_offset(&editable_350),
-        editable_512: Some(to_offset(&editable_512)),
-        editable_150_context_350: to_offset(&editable_150_context_350),
-        editable_180_context_350: to_offset(&editable_180_context_350),
-        editable_350_context_150: to_offset(&editable_350_context_150),
-        editable_350_context_512: Some(to_offset(&editable_350_context_512)),
-        editable_350_context_1024: Some(to_offset(&editable_350_context_1024)),
-        context_4096: Some(to_offset(&context_4096)),
-        context_8192: Some(to_offset(&context_8192)),
-    };
-
-    (full_context, full_context_offset_range, ranges)
+    cursor_offset: usize,
+) -> (Range<Point>, Range<usize>, usize) {
+    let cursor_point = cursor_offset.to_point(snapshot);
+    let cursor_row = cursor_point.row;
+    let (start_row, end_row, _) =
+        expand_symmetric_from_cursor(snapshot, cursor_row, CURSOR_EXCERPT_TOKEN_BUDGET);
+
+    let excerpt_range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row));
+    let excerpt_offset_range = excerpt_range.to_offset(snapshot);
+    let cursor_offset_in_excerpt = cursor_offset - excerpt_offset_range.start;
+
+    (
+        excerpt_range,
+        excerpt_offset_range,
+        cursor_offset_in_excerpt,
+    )
 }
 
-pub fn editable_and_context_ranges_for_cursor_position(
-    position: Point,
+/// Expands symmetrically from cursor, one line at a time, alternating down then up.
+/// Returns (start_row, end_row, remaining_tokens).
+fn expand_symmetric_from_cursor(
     snapshot: &BufferSnapshot,
-    editable_region_token_limit: usize,
-    context_token_limit: usize,
-) -> (Range<Point>, Range<Point>) {
-    let editable_range = compute_editable_range(snapshot, position, editable_region_token_limit);
+    cursor_row: u32,
+    mut token_budget: usize,
+) -> (u32, u32, usize) {
+    let mut start_row = cursor_row;
+    let mut end_row = cursor_row;
+
+    let cursor_line_tokens = line_token_count(snapshot, cursor_row);
+    token_budget = token_budget.saturating_sub(cursor_line_tokens);
+
+    loop {
+        let can_expand_up = start_row > 0;
+        let can_expand_down = end_row < snapshot.max_point().row;
+
+        if token_budget == 0 || (!can_expand_up && !can_expand_down) {
+            break;
+        }
 
-    let context_range = expand_context_syntactically_then_linewise(
-        snapshot,
-        editable_range.clone(),
-        context_token_limit,
-    );
+        if can_expand_down {
+            let next_row = end_row + 1;
+            let line_tokens = line_token_count(snapshot, next_row);
+            if line_tokens <= token_budget {
+                end_row = next_row;
+                token_budget = token_budget.saturating_sub(line_tokens);
+            } else {
+                break;
+            }
+        }
 
-    (editable_range, context_range)
+        if can_expand_up && token_budget > 0 {
+            let next_row = start_row - 1;
+            let line_tokens = line_token_count(snapshot, next_row);
+            if line_tokens <= token_budget {
+                start_row = next_row;
+                token_budget = token_budget.saturating_sub(line_tokens);
+            } else {
+                break;
+            }
+        }
+    }
+
+    (start_row, end_row, token_budget)
+}
+
+/// Typical number of string bytes per token for the purposes of limiting model input. This is
+/// intentionally low to err on the side of underestimating limits.
+pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3;
+
+pub fn guess_token_count(bytes: usize) -> usize {
+    bytes / BYTES_PER_TOKEN_GUESS
 }
 
-/// Computes the editable range using a three-phase approach:
-/// 1. Expand symmetrically from cursor (75% of budget)
-/// 2. Expand to syntax boundaries
-/// 3. Continue line-wise in the least-expanded direction
-fn compute_editable_range(
+fn line_token_count(snapshot: &BufferSnapshot, row: u32) -> usize {
+    guess_token_count(snapshot.line_len(row) as usize).max(1)
+}
+
+/// Computes the byte offset ranges of all syntax nodes containing the cursor,
+/// ordered from innermost to outermost. The offsets are relative to
+/// `excerpt_offset_range.start`.
+pub fn compute_syntax_ranges(
     snapshot: &BufferSnapshot,
-    cursor: Point,
-    token_limit: usize,
-) -> Range<Point> {
-    // Phase 1: Expand symmetrically from cursor using 75% of budget.
-    let initial_budget = (token_limit * 3) / 4;
-    let (mut start_row, mut end_row, mut remaining_tokens) =
-        expand_symmetric_from_cursor(snapshot, cursor.row, initial_budget);
+    cursor_offset: usize,
+    excerpt_offset_range: &Range<usize>,
+) -> Vec<Range<usize>> {
+    let cursor_point = cursor_offset.to_point(snapshot);
+    let range = cursor_point..cursor_point;
+    let mut current = snapshot.syntax_ancestor(range);
+    let mut ranges = Vec::new();
+    let mut last_range: Option<(usize, usize)> = None;
 
-    // Add remaining budget from phase 1.
-    remaining_tokens += token_limit.saturating_sub(initial_budget);
+    while let Some(node) = current.take() {
+        let node_start = node.start_byte();
+        let node_end = node.end_byte();
+        let key = (node_start, node_end);
 
-    let original_start = start_row;
-    let original_end = end_row;
+        current = node.parent();
 
-    // Phase 2: Expand to syntax boundaries that fit within budget.
+        if last_range == Some(key) {
+            continue;
+        }
+        last_range = Some(key);
+
+        let start = node_start.saturating_sub(excerpt_offset_range.start);
+        let end = node_end
+            .min(excerpt_offset_range.end)
+            .saturating_sub(excerpt_offset_range.start);
+        ranges.push(start..end);
+    }
+
+    ranges
+}
+
+/// Expands context by first trying to reach syntax boundaries,
+/// then expanding line-wise only if no syntax expansion occurred.
+pub fn expand_context_syntactically_then_linewise(
+    snapshot: &BufferSnapshot,
+    editable_range: Range<Point>,
+    context_token_limit: usize,
+) -> Range<Point> {
+    let mut start_row = editable_range.start.row;
+    let mut end_row = editable_range.end.row;
+    let mut remaining_tokens = context_token_limit;
+    let mut did_syntax_expand = false;
+
+    // Phase 1: Try to expand to containing syntax boundaries, picking the largest that fits.
     for (boundary_start, boundary_end) in containing_syntax_boundaries(snapshot, start_row, end_row)
     {
         let tokens_for_start = if boundary_start < start_row {
@@ -125,76 +158,57 @@ fn compute_editable_range(
                 end_row = boundary_end;
             }
             remaining_tokens = remaining_tokens.saturating_sub(total_needed);
+            did_syntax_expand = true;
         } else {
             break;
         }
     }
 
-    // Phase 3: Continue line-wise in the direction we expanded least during syntax phase.
-    let expanded_up = original_start.saturating_sub(start_row);
-    let expanded_down = end_row.saturating_sub(original_end);
-
-    (start_row, end_row, _) = expand_linewise_biased(
-        snapshot,
-        start_row,
-        end_row,
-        remaining_tokens,
-        expanded_up <= expanded_down, // prefer_up if we expanded less upward
-    );
+    // Phase 2: Only expand line-wise if no syntax expansion occurred.
+    if !did_syntax_expand {
+        (start_row, end_row, _) =
+            expand_linewise_biased(snapshot, start_row, end_row, remaining_tokens, true);
+    }
 
     let start = Point::new(start_row, 0);
     let end = Point::new(end_row, snapshot.line_len(end_row));
     start..end
 }
 
-/// Expands symmetrically from cursor, one line at a time, alternating down then up.
-/// Returns (start_row, end_row, remaining_tokens).
-fn expand_symmetric_from_cursor(
+/// Returns an iterator of (start_row, end_row) for successively larger syntax nodes
+/// containing the given row range. Smallest containing node first.
+fn containing_syntax_boundaries(
     snapshot: &BufferSnapshot,
-    cursor_row: u32,
-    mut token_budget: usize,
-) -> (u32, u32, usize) {
-    let mut start_row = cursor_row;
-    let mut end_row = cursor_row;
-
-    // Account for the cursor's line.
-    let cursor_line_tokens = line_token_count(snapshot, cursor_row);
-    token_budget = token_budget.saturating_sub(cursor_line_tokens);
+    start_row: u32,
+    end_row: u32,
+) -> impl Iterator<Item = (u32, u32)> {
+    let range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row));
+    let mut current = snapshot.syntax_ancestor(range);
+    let mut last_rows: Option<(u32, u32)> = None;
 
-    loop {
-        let can_expand_up = start_row > 0;
-        let can_expand_down = end_row < snapshot.max_point().row;
+    std::iter::from_fn(move || {
+        while let Some(node) = current.take() {
+            let node_start_row = node.start_position().row as u32;
+            let node_end_row = node.end_position().row as u32;
+            let rows = (node_start_row, node_end_row);
 
-        if token_budget == 0 || (!can_expand_up && !can_expand_down) {
-            break;
-        }
+            current = node.parent();
 
-        // Expand down first (slight forward bias for edit prediction).
-        if can_expand_down {
-            let next_row = end_row + 1;
-            let line_tokens = line_token_count(snapshot, next_row);
-            if line_tokens <= token_budget {
-                end_row = next_row;
-                token_budget = token_budget.saturating_sub(line_tokens);
-            } else {
-                break;
+            // Skip nodes that don't extend beyond our range.
+            if node_start_row >= start_row && node_end_row <= end_row {
+                continue;
             }
-        }
 
-        // Then expand up.
-        if can_expand_up && token_budget > 0 {
-            let next_row = start_row - 1;
-            let line_tokens = line_token_count(snapshot, next_row);
-            if line_tokens <= token_budget {
-                start_row = next_row;
-                token_budget = token_budget.saturating_sub(line_tokens);
-            } else {
-                break;
+            // Skip if same as last returned (some nodes have same span).
+            if last_rows == Some(rows) {
+                continue;
             }
-        }
-    }
 
-    (start_row, end_row, token_budget)
+            last_rows = Some(rows);
+            return Some(rows);
+        }
+        None
+    })
 }
 
 /// Expands line-wise with a bias toward one direction.
@@ -265,18 +279,6 @@ fn expand_linewise_biased(
     (start_row, end_row, remaining_tokens)
 }
 
-/// Typical number of string bytes per token for the purposes of limiting model input. This is
-/// intentionally low to err on the side of underestimating limits.
-pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3;
-
-pub fn guess_token_count(bytes: usize) -> usize {
-    bytes / BYTES_PER_TOKEN_GUESS
-}
-
-fn line_token_count(snapshot: &BufferSnapshot, row: u32) -> usize {
-    guess_token_count(snapshot.line_len(row) as usize).max(1)
-}
-
 /// Estimates token count for rows in range [start_row, end_row).
 fn estimate_tokens_for_rows(snapshot: &BufferSnapshot, start_row: u32, end_row: u32) -> usize {
     let mut tokens = 0;
@@ -286,104 +288,14 @@ fn estimate_tokens_for_rows(snapshot: &BufferSnapshot, start_row: u32, end_row:
     tokens
 }
 
-/// Returns an iterator of (start_row, end_row) for successively larger syntax nodes
-/// containing the given row range. Smallest containing node first.
-fn containing_syntax_boundaries(
-    snapshot: &BufferSnapshot,
-    start_row: u32,
-    end_row: u32,
-) -> impl Iterator<Item = (u32, u32)> {
-    let range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row));
-    let mut current = snapshot.syntax_ancestor(range);
-    let mut last_rows: Option<(u32, u32)> = None;
-
-    std::iter::from_fn(move || {
-        while let Some(node) = current.take() {
-            let node_start_row = node.start_position().row as u32;
-            let node_end_row = node.end_position().row as u32;
-            let rows = (node_start_row, node_end_row);
-
-            current = node.parent();
-
-            // Skip nodes that don't extend beyond our range.
-            if node_start_row >= start_row && node_end_row <= end_row {
-                continue;
-            }
-
-            // Skip if same as last returned (some nodes have same span).
-            if last_rows == Some(rows) {
-                continue;
-            }
-
-            last_rows = Some(rows);
-            return Some(rows);
-        }
-        None
-    })
-}
-
-/// Expands context by first trying to reach syntax boundaries,
-/// then expanding line-wise only if no syntax expansion occurred.
-fn expand_context_syntactically_then_linewise(
-    snapshot: &BufferSnapshot,
-    editable_range: Range<Point>,
-    context_token_limit: usize,
-) -> Range<Point> {
-    let mut start_row = editable_range.start.row;
-    let mut end_row = editable_range.end.row;
-    let mut remaining_tokens = context_token_limit;
-    let mut did_syntax_expand = false;
-
-    // Phase 1: Try to expand to containing syntax boundaries, picking the largest that fits.
-    for (boundary_start, boundary_end) in containing_syntax_boundaries(snapshot, start_row, end_row)
-    {
-        let tokens_for_start = if boundary_start < start_row {
-            estimate_tokens_for_rows(snapshot, boundary_start, start_row)
-        } else {
-            0
-        };
-        let tokens_for_end = if boundary_end > end_row {
-            estimate_tokens_for_rows(snapshot, end_row + 1, boundary_end + 1)
-        } else {
-            0
-        };
-
-        let total_needed = tokens_for_start + tokens_for_end;
-
-        if total_needed <= remaining_tokens {
-            if boundary_start < start_row {
-                start_row = boundary_start;
-            }
-            if boundary_end > end_row {
-                end_row = boundary_end;
-            }
-            remaining_tokens = remaining_tokens.saturating_sub(total_needed);
-            did_syntax_expand = true;
-        } else {
-            break;
-        }
-    }
-
-    // Phase 2: Only expand line-wise if no syntax expansion occurred.
-    if !did_syntax_expand {
-        (start_row, end_row, _) =
-            expand_linewise_biased(snapshot, start_row, end_row, remaining_tokens, true);
-    }
-
-    let start = Point::new(start_row, 0);
-    let end = Point::new(end_row, snapshot.line_len(end_row));
-    start..end
-}
-
-use language::ToOffset as _;
-
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::{App, AppContext};
+    use gpui::{App, AppContext as _};
     use indoc::indoc;
     use language::{Buffer, rust_lang};
     use util::test::{TextRangeMarker, marked_text_ranges_by};
+    use zeta_prompt::compute_editable_and_context_ranges;
 
     struct TestCase {
         name: &'static str,
@@ -400,7 +312,18 @@ mod tests {
         // [ ] = expected context range
         let test_cases = vec![
             TestCase {
-                name: "cursor near end of function - expands to syntax boundaries",
+                name: "small function fits entirely in editable and context",
+                marked_text: indoc! {r#"
+                    [«fn foo() {
+                        let x = 1;ˇ
+                        let y = 2;
+                    }»]
+                "#},
+                editable_token_limit: 30,
+                context_token_limit: 60,
+            },
+            TestCase {
+                name: "cursor near end of function - editable expands to syntax boundaries",
                 marked_text: indoc! {r#"
                     [fn first() {
                         let a = 1;
@@ -413,12 +336,11 @@ mod tests {
                         println!("{}", x + y);ˇ
                     }»]
                 "#},
-                // 18 tokens - expands symmetrically then to syntax boundaries
                 editable_token_limit: 18,
                 context_token_limit: 35,
             },
             TestCase {
-                name: "cursor at function start - expands to syntax boundaries",
+                name: "cursor at function start - editable expands to syntax boundaries",
                 marked_text: indoc! {r#"
                     [fn before() {
                     «    let a = 1;
@@ -434,12 +356,11 @@ mod tests {
                         let b = 2;
                     }]
                 "#},
-                // 25 tokens - expands symmetrically then to syntax boundaries
                 editable_token_limit: 25,
                 context_token_limit: 50,
             },
             TestCase {
-                name: "tiny budget - just lines around cursor",
+                name: "tiny budget - just lines around cursor, no syntax expansion",
                 marked_text: indoc! {r#"
                     fn outer() {
                     [    let line1 = 1;
@@ -451,22 +372,9 @@ mod tests {
                         let line7 = 7;
                     }
                 "#},
-                // 12 tokens (~36 bytes) = just the cursor line with tiny budget
                 editable_token_limit: 12,
                 context_token_limit: 24,
             },
-            TestCase {
-                name: "small function fits entirely",
-                marked_text: indoc! {r#"
-                    [«fn foo() {
-                        let x = 1;ˇ
-                        let y = 2;
-                    }»]
-                "#},
-                // Plenty of budget for this small function
-                editable_token_limit: 30,
-                context_token_limit: 60,
-            },
             TestCase {
                 name: "context extends beyond editable",
                 marked_text: indoc! {r#"
@@ -476,13 +384,11 @@ mod tests {
                     fn fourth() { let d = 4; }»
                     fn fifth() { let e = 5; }]
                 "#},
-                // Small editable, larger context
                 editable_token_limit: 25,
                 context_token_limit: 45,
             },
-            // Tests for syntax-aware editable and context expansion
             TestCase {
-                name: "cursor in first if-statement - expands to syntax boundaries",
+                name: "cursor in first if-block - editable expands to syntax boundaries",
                 marked_text: indoc! {r#"
                     [«fn before() { }
 
@@ -503,13 +409,11 @@ mod tests {
 
                     fn after() { }]
                 "#},
-                // 35 tokens allows expansion to include function header and first two if blocks
                 editable_token_limit: 35,
-                // 60 tokens allows context to include the whole file
                 context_token_limit: 60,
             },
             TestCase {
-                name: "cursor in middle if-statement - expands to syntax boundaries",
+                name: "cursor in middle if-block - editable spans surrounding blocks",
                 marked_text: indoc! {r#"
                     [fn before() { }
 
@@ -530,13 +434,11 @@ mod tests {
 
                     fn after() { }]
                 "#},
-                // 40 tokens allows expansion to surrounding if blocks
                 editable_token_limit: 40,
-                // 60 tokens allows context to include the whole file
                 context_token_limit: 60,
             },
             TestCase {
-                name: "cursor near bottom of long function - editable expands toward syntax, context reaches function",
+                name: "cursor near bottom of long function - context reaches function boundary",
                 marked_text: indoc! {r#"
                     [fn other() { }
 
@@ -556,11 +458,30 @@ mod tests {
 
                     fn another() { }»]
                 "#},
-                // 40 tokens for editable - allows several lines plus syntax expansion
                 editable_token_limit: 40,
-                // 55 tokens - enough for function but not whole file
                 context_token_limit: 55,
             },
+            TestCase {
+                name: "zero context budget - context equals editable",
+                marked_text: indoc! {r#"
+                    fn before() {
+                        let p = 1;
+                        let q = 2;
+                    [«}
+
+                    fn foo() {
+                        let x = 1;ˇ
+                        let y = 2;
+                    }
+                    »]
+                    fn after() {
+                        let r = 3;
+                        let s = 4;
+                    }
+                "#},
+                editable_token_limit: 15,
+                context_token_limit: 0,
+            },
         ];
 
         for test_case in test_cases {
@@ -580,75 +501,63 @@ mod tests {
             let cursor_ranges = ranges.remove(&cursor_marker).unwrap_or_default();
             let expected_editable = ranges.remove(&editable_marker).unwrap_or_default();
             let expected_context = ranges.remove(&context_marker).unwrap_or_default();
-            assert_eq!(expected_editable.len(), 1);
-            assert_eq!(expected_context.len(), 1);
+            assert_eq!(expected_editable.len(), 1, "{}", test_case.name);
+            assert_eq!(expected_context.len(), 1, "{}", test_case.name);
 
-            cx.new(|cx| {
+            cx.new(|cx: &mut gpui::Context<Buffer>| {
                 let text = text.trim_end_matches('\n');
                 let buffer = Buffer::local(text, cx).with_language(rust_lang(), cx);
                 let snapshot = buffer.snapshot();
 
                 let cursor_offset = cursor_ranges[0].start;
-                let cursor_point = snapshot.offset_to_point(cursor_offset);
-                let expected_editable_start = snapshot.offset_to_point(expected_editable[0].start);
-                let expected_editable_end = snapshot.offset_to_point(expected_editable[0].end);
-                let expected_context_start = snapshot.offset_to_point(expected_context[0].start);
-                let expected_context_end = snapshot.offset_to_point(expected_context[0].end);
-
-                let (actual_editable, actual_context) =
-                    editable_and_context_ranges_for_cursor_position(
-                        cursor_point,
-                        &snapshot,
-                        test_case.editable_token_limit,
-                        test_case.context_token_limit,
-                    );
-
-                let range_text = |start: Point, end: Point| -> String {
-                    snapshot.text_for_range(start..end).collect()
+
+                let (_, excerpt_offset_range, cursor_offset_in_excerpt) =
+                    compute_cursor_excerpt(&snapshot, cursor_offset);
+                let excerpt_text: String = snapshot
+                    .text_for_range(excerpt_offset_range.clone())
+                    .collect();
+                let syntax_ranges =
+                    compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range);
+
+                let (actual_editable, actual_context) = compute_editable_and_context_ranges(
+                    &excerpt_text,
+                    cursor_offset_in_excerpt,
+                    &syntax_ranges,
+                    test_case.editable_token_limit,
+                    test_case.context_token_limit,
+                );
+
+                let to_buffer_range = |range: Range<usize>| -> Range<usize> {
+                    (excerpt_offset_range.start + range.start)
+                        ..(excerpt_offset_range.start + range.end)
                 };
 
-                let editable_match = actual_editable.start == expected_editable_start
-                    && actual_editable.end == expected_editable_end;
-                let context_match = actual_context.start == expected_context_start
-                    && actual_context.end == expected_context_end;
+                let actual_editable = to_buffer_range(actual_editable);
+                let actual_context = to_buffer_range(actual_context);
+
+                let expected_editable_range = expected_editable[0].clone();
+                let expected_context_range = expected_context[0].clone();
+
+                let editable_match = actual_editable == expected_editable_range;
+                let context_match = actual_context == expected_context_range;
 
                 if !editable_match || !context_match {
+                    let range_text = |range: &Range<usize>| {
+                        snapshot.text_for_range(range.clone()).collect::<String>()
+                    };
+
                     println!("\n=== FAILED: {} ===", test_case.name);
                     if !editable_match {
-                        println!(
-                            "\nExpected editable ({:?}..{:?}):",
-                            expected_editable_start, expected_editable_end
-                        );
-                        println!(
-                            "---\n{}---",
-                            range_text(expected_editable_start, expected_editable_end)
-                        );
-                        println!(
-                            "\nActual editable ({:?}..{:?}):",
-                            actual_editable.start, actual_editable.end
-                        );
-                        println!(
-                            "---\n{}---",
-                            range_text(actual_editable.start, actual_editable.end)
-                        );
+                        println!("\nExpected editable ({:?}):", expected_editable_range);
+                        println!("---\n{}---", range_text(&expected_editable_range));
+                        println!("\nActual editable ({:?}):", actual_editable);
+                        println!("---\n{}---", range_text(&actual_editable));
                     }
                     if !context_match {
-                        println!(
-                            "\nExpected context ({:?}..{:?}):",
-                            expected_context_start, expected_context_end
-                        );
-                        println!(
-                            "---\n{}---",
-                            range_text(expected_context_start, expected_context_end)
-                        );
-                        println!(
-                            "\nActual context ({:?}..{:?}):",
-                            actual_context.start, actual_context.end
-                        );
-                        println!(
-                            "---\n{}---",
-                            range_text(actual_context.start, actual_context.end)
-                        );
+                        println!("\nExpected context ({:?}):", expected_context_range);
+                        println!("---\n{}---", range_text(&expected_context_range));
+                        println!("\nActual context ({:?}):", actual_context);
+                        println!("---\n{}---", range_text(&actual_context));
                     }
                     panic!("Test '{}' failed - see output above", test_case.name);
                 }

crates/edit_prediction/src/edit_prediction.rs 🔗

@@ -23,14 +23,14 @@ use futures::{
 use gpui::BackgroundExecutor;
 use gpui::http_client::Url;
 use gpui::{
-    App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions,
+    App, AsyncApp, Entity, EntityId, Global, SharedString, Task, WeakEntity, actions,
     http_client::{self, AsyncBody, Method},
     prelude::*,
 };
 use language::language_settings::all_language_settings;
 use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint};
 use language::{BufferSnapshot, OffsetRangeExt};
-use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener};
+use language_model::{LlmApiToken, NeedsLlmTokenRefresh};
 use project::{DisableAiSettings, Project, ProjectPath, WorktreeId};
 use release_channel::AppVersion;
 use semver::Version;
@@ -53,7 +53,6 @@ use std::sync::Arc;
 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
 use thiserror::Error;
 use util::{RangeExt as _, ResultExt as _};
-use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
 
 pub mod cursor_excerpt;
 pub mod example_spec;
@@ -76,6 +75,7 @@ pub mod zeta;
 #[cfg(test)]
 mod edit_prediction_tests;
 
+use crate::cursor_excerpt::expand_context_syntactically_then_linewise;
 use crate::example_spec::ExampleSpec;
 use crate::license_detection::LicenseDetectionWatcher;
 use crate::mercury::Mercury;
@@ -100,8 +100,9 @@ actions!(
 );
 
 /// Maximum number of events to track.
-const EVENT_COUNT_MAX: usize = 6;
+const EVENT_COUNT_MAX: usize = 10;
 const CHANGE_GROUPING_LINE_SPAN: u32 = 8;
+const COLLABORATOR_EDIT_LOCALITY_CONTEXT_TOKENS: usize = 512;
 const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1);
 const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice";
 const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15);
@@ -134,7 +135,6 @@ pub struct EditPredictionStore {
     client: Arc<Client>,
     user_store: Entity<UserStore>,
     llm_token: LlmApiToken,
-    _llm_token_subscription: Subscription,
     _fetch_experiments_task: Task<()>,
     projects: HashMap<EntityId, ProjectState>,
     update_required: bool,
@@ -244,21 +244,31 @@ pub enum UserActionType {
 pub struct StoredEvent {
     pub event: Arc<zeta_prompt::Event>,
     pub old_snapshot: TextBufferSnapshot,
-    pub edit_range: Range<Anchor>,
+    pub new_snapshot_version: clock::Global,
+    pub total_edit_range: Range<Anchor>,
 }
 
 impl StoredEvent {
     fn can_merge(
         &self,
-        next_old_event: &&&StoredEvent,
-        new_snapshot: &TextBufferSnapshot,
-        last_edit_range: &Range<Anchor>,
+        next_old_event: &StoredEvent,
+        latest_snapshot: &TextBufferSnapshot,
+        latest_edit_range: &Range<Anchor>,
     ) -> bool {
-        // Events must be for the same buffer
+        // Events must be for the same buffer and be contiguous across included snapshots to be mergeable.
         if self.old_snapshot.remote_id() != next_old_event.old_snapshot.remote_id() {
             return false;
         }
-        if self.old_snapshot.remote_id() != new_snapshot.remote_id() {
+        if self.old_snapshot.remote_id() != latest_snapshot.remote_id() {
+            return false;
+        }
+        if self.new_snapshot_version != next_old_event.old_snapshot.version {
+            return false;
+        }
+        if !latest_snapshot
+            .version
+            .observed_all(&next_old_event.new_snapshot_version)
+        {
             return false;
         }
 
@@ -283,9 +293,9 @@ impl StoredEvent {
             return false;
         }
 
-        let left_range = self.edit_range.to_point(new_snapshot);
-        let right_range = next_old_event.edit_range.to_point(new_snapshot);
-        let latest_range = last_edit_range.to_point(&new_snapshot);
+        let left_range = self.total_edit_range.to_point(latest_snapshot);
+        let right_range = next_old_event.total_edit_range.to_point(latest_snapshot);
+        let latest_range = latest_edit_range.to_point(latest_snapshot);
 
         // Events near to the latest edit are not merged if their sources differ.
         if lines_between_ranges(&left_range, &latest_range)
@@ -518,7 +528,9 @@ struct LastEvent {
     new_snapshot: TextBufferSnapshot,
     old_file: Option<Arc<dyn File>>,
     new_file: Option<Arc<dyn File>>,
-    edit_range: Option<Range<Anchor>>,
+    latest_edit_range: Range<Anchor>,
+    total_edit_range: Range<Anchor>,
+    total_edit_range_at_last_pause_boundary: Option<Range<Anchor>>,
     predicted: bool,
     snapshot_after_last_editing_pause: Option<TextBufferSnapshot>,
     last_edit_time: Option<Instant>,
@@ -544,8 +556,11 @@ impl LastEvent {
                     })
                 });
 
-        let (diff, edit_range) =
-            compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?;
+        let (diff, edit_range) = compute_diff_between_snapshots_in_range(
+            &self.old_snapshot,
+            &self.new_snapshot,
+            &self.total_edit_range,
+        )?;
 
         if path == old_path && diff.is_empty() {
             None
@@ -558,9 +573,10 @@ impl LastEvent {
                     in_open_source_repo,
                     predicted: self.predicted,
                 }),
-                edit_range: self.new_snapshot.anchor_before(edit_range.start)
-                    ..self.new_snapshot.anchor_before(edit_range.end),
                 old_snapshot: self.old_snapshot.clone(),
+                new_snapshot_version: self.new_snapshot.version.clone(),
+                total_edit_range: self.new_snapshot.anchor_before(edit_range.start)
+                    ..self.new_snapshot.anchor_before(edit_range.end),
             })
         }
     }
@@ -570,12 +586,28 @@ impl LastEvent {
             return (self.clone(), None);
         };
 
+        let total_edit_range_before_pause = self
+            .total_edit_range_at_last_pause_boundary
+            .clone()
+            .unwrap_or_else(|| self.total_edit_range.clone());
+
+        let Some(total_edit_range_after_pause) =
+            compute_total_edit_range_between_snapshots(boundary_snapshot, &self.new_snapshot)
+        else {
+            return (self.clone(), None);
+        };
+
+        let latest_edit_range_before_pause = total_edit_range_before_pause.clone();
+        let latest_edit_range_after_pause = total_edit_range_after_pause.clone();
+
         let before = LastEvent {
             old_snapshot: self.old_snapshot.clone(),
             new_snapshot: boundary_snapshot.clone(),
             old_file: self.old_file.clone(),
             new_file: self.new_file.clone(),
-            edit_range: None,
+            latest_edit_range: latest_edit_range_before_pause,
+            total_edit_range: total_edit_range_before_pause,
+            total_edit_range_at_last_pause_boundary: None,
             predicted: self.predicted,
             snapshot_after_last_editing_pause: None,
             last_edit_time: self.last_edit_time,
@@ -586,7 +618,9 @@ impl LastEvent {
             new_snapshot: self.new_snapshot.clone(),
             old_file: self.old_file.clone(),
             new_file: self.new_file.clone(),
-            edit_range: None,
+            latest_edit_range: latest_edit_range_after_pause,
+            total_edit_range: total_edit_range_after_pause,
+            total_edit_range_at_last_pause_boundary: None,
             predicted: self.predicted,
             snapshot_after_last_editing_pause: None,
             last_edit_time: self.last_edit_time,
@@ -596,21 +630,78 @@ impl LastEvent {
     }
 }
 
-pub(crate) fn compute_diff_between_snapshots(
+fn compute_total_edit_range_between_snapshots(
     old_snapshot: &TextBufferSnapshot,
     new_snapshot: &TextBufferSnapshot,
-) -> Option<(String, Range<Point>)> {
+) -> Option<Range<Anchor>> {
     let edits: Vec<Edit<usize>> = new_snapshot
         .edits_since::<usize>(&old_snapshot.version)
         .collect();
 
     let (first_edit, last_edit) = edits.first().zip(edits.last())?;
-
-    let old_start_point = old_snapshot.offset_to_point(first_edit.old.start);
-    let old_end_point = old_snapshot.offset_to_point(last_edit.old.end);
     let new_start_point = new_snapshot.offset_to_point(first_edit.new.start);
     let new_end_point = new_snapshot.offset_to_point(last_edit.new.end);
 
+    Some(new_snapshot.anchor_before(new_start_point)..new_snapshot.anchor_before(new_end_point))
+}
+
+fn compute_old_range_for_new_range(
+    old_snapshot: &TextBufferSnapshot,
+    new_snapshot: &TextBufferSnapshot,
+    total_edit_range: &Range<Anchor>,
+) -> Option<Range<Point>> {
+    let new_start_offset = total_edit_range.start.to_offset(new_snapshot);
+    let new_end_offset = total_edit_range.end.to_offset(new_snapshot);
+
+    let edits: Vec<Edit<usize>> = new_snapshot
+        .edits_since::<usize>(&old_snapshot.version)
+        .collect();
+    let mut old_start_offset = None;
+    let mut old_end_offset = None;
+    let mut delta: isize = 0;
+
+    for edit in &edits {
+        if old_start_offset.is_none() && new_start_offset <= edit.new.end {
+            old_start_offset = Some(if new_start_offset < edit.new.start {
+                new_start_offset.checked_add_signed(-delta)?
+            } else {
+                edit.old.start
+            });
+        }
+
+        if old_end_offset.is_none() && new_end_offset <= edit.new.end {
+            old_end_offset = Some(if new_end_offset < edit.new.start {
+                new_end_offset.checked_add_signed(-delta)?
+            } else {
+                edit.old.end
+            });
+        }
+
+        delta += edit.new.len() as isize - edit.old.len() as isize;
+    }
+
+    let old_start_offset =
+        old_start_offset.unwrap_or_else(|| new_start_offset.saturating_add_signed(-delta));
+    let old_end_offset =
+        old_end_offset.unwrap_or_else(|| new_end_offset.saturating_add_signed(-delta));
+
+    Some(
+        old_snapshot.offset_to_point(old_start_offset)
+            ..old_snapshot.offset_to_point(old_end_offset),
+    )
+}
+
+fn compute_diff_between_snapshots_in_range(
+    old_snapshot: &TextBufferSnapshot,
+    new_snapshot: &TextBufferSnapshot,
+    total_edit_range: &Range<Anchor>,
+) -> Option<(String, Range<Point>)> {
+    let new_start_point = total_edit_range.start.to_point(new_snapshot);
+    let new_end_point = total_edit_range.end.to_point(new_snapshot);
+    let old_range = compute_old_range_for_new_range(old_snapshot, new_snapshot, total_edit_range)?;
+    let old_start_point = old_range.start;
+    let old_end_point = old_range.end;
+
     const CONTEXT_LINES: u32 = 3;
 
     let old_context_start_row = old_start_point.row.saturating_sub(CONTEXT_LINES);
@@ -675,10 +766,9 @@ impl EditPredictionStore {
     }
 
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
-        let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
         let data_collection_choice = Self::load_data_collection_choice();
 
-        let llm_token = LlmApiToken::default();
+        let llm_token = LlmApiToken::global(cx);
 
         let (reject_tx, reject_rx) = mpsc::unbounded();
         cx.background_spawn({
@@ -722,23 +812,6 @@ impl EditPredictionStore {
             user_store,
             llm_token,
             _fetch_experiments_task: fetch_experiments_task,
-            _llm_token_subscription: cx.subscribe(
-                &refresh_llm_token_listener,
-                |this, _listener, _event, cx| {
-                    let client = this.client.clone();
-                    let llm_token = this.llm_token.clone();
-                    let organization_id = this
-                        .user_store
-                        .read(cx)
-                        .current_organization()
-                        .map(|organization| organization.id.clone());
-                    cx.spawn(async move |_this, _cx| {
-                        llm_token.refresh(&client, organization_id).await?;
-                        anyhow::Ok(())
-                    })
-                    .detach_and_log_err(cx);
-                },
-            ),
             update_required: false,
             edit_prediction_model: EditPredictionModel::Zeta,
             zeta2_raw_config: Self::zeta2_raw_config_from_env(),
@@ -894,6 +967,10 @@ impl EditPredictionStore {
         self.mercury.api_token.read(cx).has_key()
     }
 
+    pub fn mercury_has_payment_required_error(&self) -> bool {
+        self.mercury.has_payment_required_error()
+    }
+
     pub fn clear_history(&mut self) {
         for project_state in self.projects.values_mut() {
             project_state.events.clear();
@@ -1218,10 +1295,12 @@ impl EditPredictionStore {
                         cx.subscribe(buffer, {
                             let project = project.downgrade();
                             move |this, buffer, event, cx| {
-                                if let language::BufferEvent::Edited = event
+                                if let language::BufferEvent::Edited { is_local } = event
                                     && let Some(project) = project.upgrade()
                                 {
-                                    this.report_changes_for_buffer(&buffer, &project, false, cx);
+                                    this.report_changes_for_buffer(
+                                        &buffer, &project, false, *is_local, cx,
+                                    );
                                 }
                             }
                         }),
@@ -1243,6 +1322,7 @@ impl EditPredictionStore {
         buffer: &Entity<Buffer>,
         project: &Entity<Project>,
         is_predicted: bool,
+        is_local: bool,
         cx: &mut Context<Self>,
     ) {
         let project_state = self.get_or_init_project(project, cx);
@@ -1254,7 +1334,6 @@ impl EditPredictionStore {
         if new_snapshot.version == registered_buffer.snapshot.version {
             return;
         }
-
         let old_file = mem::replace(&mut registered_buffer.file, new_file.clone());
         let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone());
         let mut num_edits = 0usize;
@@ -1287,28 +1366,44 @@ impl EditPredictionStore {
             }
         }
 
-        let action_type = match (total_deleted, total_inserted, num_edits) {
-            (0, ins, n) if ins == n => UserActionType::InsertChar,
-            (0, _, _) => UserActionType::InsertSelection,
-            (del, 0, n) if del == n => UserActionType::DeleteChar,
-            (_, 0, _) => UserActionType::DeleteSelection,
-            (_, ins, n) if ins == n => UserActionType::InsertChar,
-            (_, _, _) => UserActionType::InsertSelection,
-        };
+        let include_in_history = is_local
+            || collaborator_edit_overlaps_locality_region(
+                project_state,
+                project,
+                buffer,
+                &buf.snapshot(),
+                &edit_range,
+                cx,
+            );
 
-        if let Some(offset) = last_offset {
-            let point = new_snapshot.offset_to_point(offset);
-            let timestamp_epoch_ms = SystemTime::now()
-                .duration_since(UNIX_EPOCH)
-                .map(|d| d.as_millis() as u64)
-                .unwrap_or(0);
-            project_state.record_user_action(UserActionRecord {
-                action_type,
-                buffer_id: buffer.entity_id(),
-                line_number: point.row,
-                offset,
-                timestamp_epoch_ms,
-            });
+        if is_local {
+            let action_type = match (total_deleted, total_inserted, num_edits) {
+                (0, ins, n) if ins == n => UserActionType::InsertChar,
+                (0, _, _) => UserActionType::InsertSelection,
+                (del, 0, n) if del == n => UserActionType::DeleteChar,
+                (_, 0, _) => UserActionType::DeleteSelection,
+                (_, ins, n) if ins == n => UserActionType::InsertChar,
+                (_, _, _) => UserActionType::InsertSelection,
+            };
+
+            if let Some(offset) = last_offset {
+                let point = new_snapshot.offset_to_point(offset);
+                let timestamp_epoch_ms = SystemTime::now()
+                    .duration_since(UNIX_EPOCH)
+                    .map(|d| d.as_millis() as u64)
+                    .unwrap_or(0);
+                project_state.record_user_action(UserActionRecord {
+                    action_type,
+                    buffer_id: buffer.entity_id(),
+                    line_number: point.row,
+                    offset,
+                    timestamp_epoch_ms,
+                });
+            }
+        }
+
+        if !include_in_history {
+            return;
         }
 
         let events = &mut project_state.events;
@@ -1322,15 +1417,10 @@ impl EditPredictionStore {
 
             let should_coalesce = is_next_snapshot_of_same_buffer
                 && !prediction_source_changed
-                && last_event
-                    .edit_range
-                    .as_ref()
-                    .is_some_and(|last_edit_range| {
-                        lines_between_ranges(
-                            &edit_range.to_point(&new_snapshot),
-                            &last_edit_range.to_point(&new_snapshot),
-                        ) <= CHANGE_GROUPING_LINE_SPAN
-                    });
+                && lines_between_ranges(
+                    &edit_range.to_point(&new_snapshot),
+                    &last_event.latest_edit_range.to_point(&new_snapshot),
+                ) <= CHANGE_GROUPING_LINE_SPAN;
 
             if should_coalesce {
                 let pause_elapsed = last_event
@@ -1340,9 +1430,13 @@ impl EditPredictionStore {
                 if pause_elapsed {
                     last_event.snapshot_after_last_editing_pause =
                         Some(last_event.new_snapshot.clone());
+                    last_event.total_edit_range_at_last_pause_boundary =
+                        Some(last_event.total_edit_range.clone());
                 }
 
-                last_event.edit_range = Some(edit_range);
+                last_event.latest_edit_range = edit_range.clone();
+                last_event.total_edit_range =
+                    merge_anchor_ranges(&last_event.total_edit_range, &edit_range, &new_snapshot);
                 last_event.new_snapshot = new_snapshot;
                 last_event.last_edit_time = Some(now);
                 return;
@@ -1365,7 +1459,9 @@ impl EditPredictionStore {
             new_file,
             old_snapshot,
             new_snapshot,
-            edit_range: Some(edit_range),
+            latest_edit_range: edit_range.clone(),
+            total_edit_range: edit_range,
+            total_edit_range_at_last_pause_boundary: None,
             predicted: is_predicted,
             snapshot_after_last_editing_pause: None,
             last_edit_time: Some(now),
@@ -1421,7 +1517,13 @@ impl EditPredictionStore {
             return;
         };
 
-        self.report_changes_for_buffer(&current_prediction.prediction.buffer, project, true, cx);
+        self.report_changes_for_buffer(
+            &current_prediction.prediction.buffer,
+            project,
+            true,
+            true,
+            cx,
+        );
 
         // can't hold &mut project_state ref across report_changes_for_buffer_call
         let Some(project_state) = self.projects.get_mut(&project.entity_id()) else {
@@ -2470,49 +2572,6 @@ impl EditPredictionStore {
         .await
     }
 
-    fn handle_api_response<T>(
-        this: &WeakEntity<Self>,
-        response: Result<(T, Option<EditPredictionUsage>)>,
-        cx: &mut gpui::AsyncApp,
-    ) -> Result<T> {
-        match response {
-            Ok((data, usage)) => {
-                if let Some(usage) = usage {
-                    this.update(cx, |this, cx| {
-                        this.user_store.update(cx, |user_store, cx| {
-                            user_store.update_edit_prediction_usage(usage, cx);
-                        });
-                    })
-                    .ok();
-                }
-                Ok(data)
-            }
-            Err(err) => {
-                if err.is::<ZedUpdateRequiredError>() {
-                    cx.update(|cx| {
-                        this.update(cx, |this, _cx| {
-                            this.update_required = true;
-                        })
-                        .ok();
-
-                        let error_message: SharedString = err.to_string().into();
-                        show_app_notification(
-                            NotificationId::unique::<ZedUpdateRequiredError>(),
-                            cx,
-                            move |cx| {
-                                cx.new(|cx| {
-                                    ErrorMessagePrompt::new(error_message.clone(), cx)
-                                        .with_link_button("Update Zed", "https://zed.dev/releases")
-                                })
-                            },
-                        );
-                    });
-                }
-                Err(err)
-            }
-        }
-    }
-
     async fn send_api_request<Res>(
         build: impl Fn(http_client::http::request::Builder) -> Result<http_client::Request<AsyncBody>>,
         client: Arc<Client>,
@@ -2733,6 +2792,32 @@ impl EditPredictionStore {
     }
 }
 
+fn collaborator_edit_overlaps_locality_region(
+    project_state: &ProjectState,
+    project: &Entity<Project>,
+    buffer: &Entity<Buffer>,
+    snapshot: &BufferSnapshot,
+    edit_range: &Range<Anchor>,
+    cx: &App,
+) -> bool {
+    let Some((active_buffer, Some(position))) = project_state.active_buffer(project, cx) else {
+        return false;
+    };
+
+    if active_buffer.entity_id() != buffer.entity_id() {
+        return false;
+    }
+
+    let locality_point_range = expand_context_syntactically_then_linewise(
+        snapshot,
+        (position..position).to_point(snapshot),
+        COLLABORATOR_EDIT_LOCALITY_CONTEXT_TOKENS,
+    );
+    let locality_anchor_range = snapshot.anchor_range_around(locality_point_range);
+
+    edit_range.overlaps(&locality_anchor_range, snapshot)
+}
+
 fn merge_trailing_events_if_needed(
     events: &mut VecDeque<StoredEvent>,
     end_snapshot: &TextBufferSnapshot,
@@ -2743,13 +2828,19 @@ fn merge_trailing_events_if_needed(
         if last_event.old_snapshot.remote_id() != latest_snapshot.remote_id() {
             return;
         }
+        if !latest_snapshot
+            .version
+            .observed_all(&last_event.new_snapshot_version)
+        {
+            return;
+        }
     }
 
     let mut next_old_event = None;
     let mut mergeable_count = 0;
     for old_event in events.iter().rev() {
-        if let Some(next_old_event) = &next_old_event
-            && !old_event.can_merge(&next_old_event, latest_snapshot, latest_edit_range)
+        if let Some(next_old_event) = next_old_event
+            && !old_event.can_merge(next_old_event, latest_snapshot, latest_edit_range)
         {
             break;
         }
@@ -2764,10 +2855,19 @@ fn merge_trailing_events_if_needed(
     let mut events_to_merge = events.range(events.len() - mergeable_count..).peekable();
     let oldest_event = events_to_merge.peek().unwrap();
     let oldest_snapshot = oldest_event.old_snapshot.clone();
+    let newest_snapshot = end_snapshot;
+    let mut merged_edit_range = oldest_event.total_edit_range.clone();
 
-    if let Some((diff, edited_range)) =
-        compute_diff_between_snapshots(&oldest_snapshot, end_snapshot)
-    {
+    for event in events.range(events.len() - mergeable_count + 1..) {
+        merged_edit_range =
+            merge_anchor_ranges(&merged_edit_range, &event.total_edit_range, latest_snapshot);
+    }
+
+    if let Some((diff, edit_range)) = compute_diff_between_snapshots_in_range(
+        &oldest_snapshot,
+        newest_snapshot,
+        &merged_edit_range,
+    ) {
         let merged_event = match oldest_event.event.as_ref() {
             zeta_prompt::Event::BufferChange {
                 old_path,
@@ -2791,8 +2891,9 @@ fn merge_trailing_events_if_needed(
                     }),
                 }),
                 old_snapshot: oldest_snapshot.clone(),
-                edit_range: end_snapshot.anchor_before(edited_range.start)
-                    ..end_snapshot.anchor_before(edited_range.end),
+                new_snapshot_version: newest_snapshot.version.clone(),
+                total_edit_range: newest_snapshot.anchor_before(edit_range.start)
+                    ..newest_snapshot.anchor_before(edit_range.end),
             },
         };
         events.truncate(events.len() - mergeable_count);
@@ -2800,21 +2901,22 @@ fn merge_trailing_events_if_needed(
     }
 }
 
-pub(crate) fn filter_redundant_excerpts(
-    mut related_files: Vec<RelatedFile>,
-    cursor_path: &Path,
-    cursor_row_range: Range<u32>,
-) -> Vec<RelatedFile> {
-    for file in &mut related_files {
-        if file.path.as_ref() == cursor_path {
-            file.excerpts.retain(|excerpt| {
-                excerpt.row_range.start < cursor_row_range.start
-                    || excerpt.row_range.end > cursor_row_range.end
-            });
-        }
-    }
-    related_files.retain(|file| !file.excerpts.is_empty());
-    related_files
+fn merge_anchor_ranges(
+    left: &Range<Anchor>,
+    right: &Range<Anchor>,
+    snapshot: &TextBufferSnapshot,
+) -> Range<Anchor> {
+    let start = if left.start.cmp(&right.start, snapshot).is_le() {
+        left.start
+    } else {
+        right.start
+    };
+    let end = if left.end.cmp(&right.end, snapshot).is_ge() {
+        left.end
+    } else {
+        right.end
+    };
+    start..end
 }
 
 #[derive(Error, Debug)]

crates/edit_prediction/src/edit_prediction_tests.rs 🔗

@@ -1,7 +1,8 @@
 use super::*;
-use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string};
+use crate::udiff::apply_diff_to_string;
 use client::{UserStore, test::FakeServer};
 use clock::FakeSystemClock;
+use clock::ReplicaId;
 use cloud_api_types::{CreateLlmTokenResponse, LlmToken};
 use cloud_llm_client::{
     EditPredictionRejectReason, EditPredictionRejection, RejectEditPredictionsBody,
@@ -17,15 +18,22 @@ use gpui::{
     http_client::{FakeHttpClient, Response},
 };
 use indoc::indoc;
-use language::{Anchor, Buffer, CursorShape, Operation, Point, Selection, SelectionGoal};
+use language::{
+    Anchor, Buffer, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet,
+    DiagnosticSeverity, Operation, Point, Selection, SelectionGoal,
+};
+use language_model::RefreshLlmTokenListener;
 use lsp::LanguageServerId;
 use parking_lot::Mutex;
 use pretty_assertions::{assert_eq, assert_matches};
 use project::{FakeFs, Project};
 use serde_json::json;
 use settings::SettingsStore;
-use std::{path::Path, sync::Arc, time::Duration};
-use util::path;
+use std::{ops::Range, path::Path, sync::Arc, time::Duration};
+use util::{
+    path,
+    test::{TextRangeMarker, marked_text_ranges_by},
+};
 use uuid::Uuid;
 use zeta_prompt::ZetaPromptInput;
 
@@ -363,6 +371,12 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
         ep_store.edit_history_for_project(&project, cx)
     });
     assert_eq!(events.len(), 2);
+
+    let first_total_edit_range = buffer.read_with(cx, |buffer, _| {
+        events[0].total_edit_range.to_point(&buffer.snapshot())
+    });
+    assert_eq!(first_total_edit_range, Point::new(1, 0)..Point::new(1, 3));
+
     let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref();
     assert_eq!(
         diff.as_str(),
@@ -375,6 +389,11 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
         "}
     );
 
+    let second_total_edit_range = buffer.read_with(cx, |buffer, _| {
+        events[1].total_edit_range.to_point(&buffer.snapshot())
+    });
+    assert_eq!(second_total_edit_range, Point::new(1, 3)..Point::new(1, 13));
+
     let zeta_prompt::Event::BufferChange { diff, .. } = events[1].event.as_ref();
     assert_eq!(
         diff.as_str(),
@@ -591,6 +610,240 @@ fn render_events_with_predicted(events: &[StoredEvent]) -> Vec<String> {
         .collect()
 }
 
+fn make_collaborator_replica(
+    buffer: &Entity<Buffer>,
+    cx: &mut TestAppContext,
+) -> (Entity<Buffer>, clock::Global) {
+    let (state, version) =
+        buffer.read_with(cx, |buffer, _cx| (buffer.to_proto(_cx), buffer.version()));
+    let collaborator = cx.new(|_cx| {
+        Buffer::from_proto(ReplicaId::new(1), Capability::ReadWrite, state, None).unwrap()
+    });
+    (collaborator, version)
+}
+
+async fn apply_collaborator_edit(
+    collaborator: &Entity<Buffer>,
+    buffer: &Entity<Buffer>,
+    since_version: &mut clock::Global,
+    edit_range: Range<usize>,
+    new_text: &str,
+    cx: &mut TestAppContext,
+) {
+    collaborator.update(cx, |collaborator, cx| {
+        collaborator.edit([(edit_range, new_text)], None, cx);
+    });
+
+    let serialize_task = collaborator.read_with(cx, |collaborator, cx| {
+        collaborator.serialize_ops(Some(since_version.clone()), cx)
+    });
+    let ops = serialize_task.await;
+    *since_version = collaborator.read_with(cx, |collaborator, _cx| collaborator.version());
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.apply_ops(
+            ops.into_iter()
+                .map(|op| language::proto::deserialize_operation(op).unwrap()),
+            cx,
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_nearby_collaborator_edits_are_kept_in_history(cx: &mut TestAppContext) {
+    let (ep_store, _requests) = init_test_with_fake_client(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "foo.rs": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\n"
+        }),
+    )
+    .await;
+    let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap();
+            project.set_active_path(Some(path.clone()), cx);
+            project.open_buffer(path, cx)
+        })
+        .await
+        .unwrap();
+
+    let cursor = buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0)));
+
+    ep_store.update(cx, |ep_store, cx| {
+        ep_store.register_buffer(&buffer, &project, cx);
+        let _ = ep_store.prediction_at(&buffer, Some(cursor), &project, cx);
+    });
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit(vec![(0..6, "LOCAL ZERO")], None, cx);
+    });
+
+    let (collaborator, mut collaborator_version) = make_collaborator_replica(&buffer, cx);
+
+    let (line_one_start, line_one_len) = collaborator.read_with(cx, |buffer, _cx| {
+        (Point::new(1, 0).to_offset(buffer), buffer.line_len(1))
+    });
+
+    apply_collaborator_edit(
+        &collaborator,
+        &buffer,
+        &mut collaborator_version,
+        line_one_start..line_one_start + line_one_len as usize,
+        "REMOTE ONE",
+        cx,
+    )
+    .await;
+
+    let events = ep_store.update(cx, |ep_store, cx| {
+        ep_store.edit_history_for_project(&project, cx)
+    });
+
+    assert_eq!(
+        render_events_with_predicted(&events),
+        vec![indoc! {"
+            manual
+            @@ -1,5 +1,5 @@
+            -line 0
+            -line 1
+            +LOCAL ZERO
+            +REMOTE ONE
+             line 2
+             line 3
+             line 4
+        "}]
+    );
+}
+
+#[gpui::test]
+async fn test_distant_collaborator_edits_are_omitted_from_history(cx: &mut TestAppContext) {
+    let (ep_store, _requests) = init_test_with_fake_client(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "foo.rs": (0..1000)
+                .map(|i| format!("line {i}\n"))
+                .collect::<String>()
+        }),
+    )
+    .await;
+    let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap();
+            project.set_active_path(Some(path.clone()), cx);
+            project.open_buffer(path, cx)
+        })
+        .await
+        .unwrap();
+
+    let cursor = buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0)));
+
+    ep_store.update(cx, |ep_store, cx| {
+        ep_store.register_buffer(&buffer, &project, cx);
+        let _ = ep_store.prediction_at(&buffer, Some(cursor), &project, cx);
+    });
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit(vec![(0..6, "LOCAL ZERO")], None, cx);
+    });
+
+    let (collaborator, mut collaborator_version) = make_collaborator_replica(&buffer, cx);
+
+    let far_line_start = buffer.read_with(cx, |buffer, _cx| Point::new(900, 0).to_offset(buffer));
+
+    apply_collaborator_edit(
+        &collaborator,
+        &buffer,
+        &mut collaborator_version,
+        far_line_start..far_line_start + 7,
+        "REMOTE FAR",
+        cx,
+    )
+    .await;
+
+    let events = ep_store.update(cx, |ep_store, cx| {
+        ep_store.edit_history_for_project(&project, cx)
+    });
+
+    assert_eq!(
+        render_events_with_predicted(&events),
+        vec![indoc! {"
+            manual
+            @@ -1,4 +1,4 @@
+            -line 0
+            +LOCAL ZERO
+             line 1
+             line 2
+             line 3
+        "}]
+    );
+}
+
+#[gpui::test]
+async fn test_irrelevant_collaborator_edits_in_different_files_are_omitted_from_history(
+    cx: &mut TestAppContext,
+) {
+    let (ep_store, _requests) = init_test_with_fake_client(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "foo.rs": "line 0\nline 1\nline 2\nline 3\n",
+            "bar.rs": "line 0\nline 1\nline 2\nline 3\n"
+        }),
+    )
+    .await;
+    let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
+
+    let foo_buffer = project
+        .update(cx, |project, cx| {
+            let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap();
+            project.set_active_path(Some(path.clone()), cx);
+            project.open_buffer(path, cx)
+        })
+        .await
+        .unwrap();
+    let bar_buffer = project
+        .update(cx, |project, cx| {
+            let path = project.find_project_path(path!("root/bar.rs"), cx).unwrap();
+            project.open_buffer(path, cx)
+        })
+        .await
+        .unwrap();
+
+    let foo_cursor = foo_buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0)));
+
+    ep_store.update(cx, |ep_store, cx| {
+        ep_store.register_buffer(&foo_buffer, &project, cx);
+        ep_store.register_buffer(&bar_buffer, &project, cx);
+        let _ = ep_store.prediction_at(&foo_buffer, Some(foo_cursor), &project, cx);
+    });
+
+    let (bar_collaborator, mut bar_version) = make_collaborator_replica(&bar_buffer, cx);
+
+    apply_collaborator_edit(
+        &bar_collaborator,
+        &bar_buffer,
+        &mut bar_version,
+        0..6,
+        "REMOTE BAR",
+        cx,
+    )
+    .await;
+
+    let events = ep_store.update(cx, |ep_store, cx| {
+        ep_store.edit_history_for_project(&project, cx)
+    });
+
+    assert!(events.is_empty());
+}
+
 #[gpui::test]
 async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) {
     let (ep_store, _requests) = init_test_with_fake_client(cx);
@@ -673,7 +926,7 @@ async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) {
             let end = Point::new(2, 6).to_offset(buffer);
             buffer.edit(vec![(offset..end, "LINE TWO")], None, cx);
         });
-        ep_store.report_changes_for_buffer(&buffer, &project, true, cx);
+        ep_store.report_changes_for_buffer(&buffer, &project, true, true, cx);
     });
 
     let events = ep_store.update(cx, |ep_store, cx| {
@@ -715,7 +968,7 @@ async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) {
             let end = Point::new(3, 6).to_offset(buffer);
             buffer.edit(vec![(offset..end, "LINE THREE")], None, cx);
         });
-        ep_store.report_changes_for_buffer(&buffer, &project, true, cx);
+        ep_store.report_changes_for_buffer(&buffer, &project, true, true, cx);
     });
 
     let events = ep_store.update(cx, |ep_store, cx| {
@@ -1656,97 +1909,172 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) {
     assert_eq!(reject_request.rejections[1].request_id, "retry-2");
 }
 
-// Skipped until we start including diagnostics in prompt
-// #[gpui::test]
-// async fn test_request_diagnostics(cx: &mut TestAppContext) {
-//     let (ep_store, mut req_rx) = init_test_with_fake_client(cx);
-//     let fs = FakeFs::new(cx.executor());
-//     fs.insert_tree(
-//         "/root",
-//         json!({
-//             "foo.md": "Hello!\nBye"
-//         }),
-//     )
-//     .await;
-//     let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await;
-
-//     let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap();
-//     let diagnostic = lsp::Diagnostic {
-//         range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)),
-//         severity: Some(lsp::DiagnosticSeverity::ERROR),
-//         message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(),
-//         ..Default::default()
-//     };
-
-//     project.update(cx, |project, cx| {
-//         project.lsp_store().update(cx, |lsp_store, cx| {
-//             // Create some diagnostics
-//             lsp_store
-//                 .update_diagnostics(
-//                     LanguageServerId(0),
-//                     lsp::PublishDiagnosticsParams {
-//                         uri: path_to_buffer_uri.clone(),
-//                         diagnostics: vec![diagnostic],
-//                         version: None,
-//                     },
-//                     None,
-//                     language::DiagnosticSourceKind::Pushed,
-//                     &[],
-//                     cx,
-//                 )
-//                 .unwrap();
-//         });
-//     });
-
-//     let buffer = project
-//         .update(cx, |project, cx| {
-//             let path = project.find_project_path(path!("root/foo.md"), cx).unwrap();
-//             project.open_buffer(path, cx)
-//         })
-//         .await
-//         .unwrap();
-
-//     let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
-//     let position = snapshot.anchor_before(language::Point::new(0, 0));
-
-//     let _prediction_task = ep_store.update(cx, |ep_store, cx| {
-//         ep_store.request_prediction(&project, &buffer, position, cx)
-//     });
-
-//     let (request, _respond_tx) = req_rx.next().await.unwrap();
-
-//     assert_eq!(request.diagnostic_groups.len(), 1);
-//     let value = serde_json::from_str::<serde_json::Value>(request.diagnostic_groups[0].0.get())
-//         .unwrap();
-//     // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3
-//     assert_eq!(
-//         value,
-//         json!({
-//             "entries": [{
-//                 "range": {
-//                     "start": 8,
-//                     "end": 10
-//                 },
-//                 "diagnostic": {
-//                     "source": null,
-//                     "code": null,
-//                     "code_description": null,
-//                     "severity": 1,
-//                     "message": "\"Hello\" deprecated. Use \"Hi\" instead",
-//                     "markdown": null,
-//                     "group_id": 0,
-//                     "is_primary": true,
-//                     "is_disk_based": false,
-//                     "is_unnecessary": false,
-//                     "source_kind": "Pushed",
-//                     "data": null,
-//                     "underline": true
-//                 }
-//             }],
-//             "primary_ix": 0
-//         })
-//     );
-// }
+#[gpui::test]
+fn test_active_buffer_diagnostics_fetching(cx: &mut TestAppContext) {
+    let diagnostic_marker: TextRangeMarker = ('«', '»').into();
+    let search_range_marker: TextRangeMarker = ('[', ']').into();
+
+    let (text, mut ranges) = marked_text_ranges_by(
+        indoc! {r#"
+            fn alpha() {
+                let «first_value» = 1;
+            }
+
+            [fn beta() {
+                let «second_value» = 2;
+                let third_value = second_value + missing_symbol;
+            }ˇ]
+
+            fn gamma() {
+                let «fourth_value» = missing_other_symbol;
+            }
+        "#},
+        vec![diagnostic_marker.clone(), search_range_marker.clone()],
+    );
+
+    let diagnostic_ranges = ranges.remove(&diagnostic_marker).unwrap_or_default();
+    let search_ranges = ranges.remove(&search_range_marker).unwrap_or_default();
+
+    let buffer = cx.new(|cx| Buffer::local(&text, cx));
+
+    buffer.update(cx, |buffer, cx| {
+        let snapshot = buffer.snapshot();
+        let diagnostics = DiagnosticSet::new(
+            diagnostic_ranges
+                .iter()
+                .enumerate()
+                .map(|(index, range)| DiagnosticEntry {
+                    range: snapshot.offset_to_point_utf16(range.start)
+                        ..snapshot.offset_to_point_utf16(range.end),
+                    diagnostic: Diagnostic {
+                        severity: match index {
+                            0 => DiagnosticSeverity::WARNING,
+                            1 => DiagnosticSeverity::ERROR,
+                            _ => DiagnosticSeverity::HINT,
+                        },
+                        message: match index {
+                            0 => "first warning".to_string(),
+                            1 => "second error".to_string(),
+                            _ => "third hint".to_string(),
+                        },
+                        group_id: index + 1,
+                        is_primary: true,
+                        source_kind: language::DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
+                    },
+                }),
+            &snapshot,
+        );
+        buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx);
+    });
+
+    let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+    let search_range = snapshot.offset_to_point(search_ranges[0].start)
+        ..snapshot.offset_to_point(search_ranges[0].end);
+
+    let active_buffer_diagnostics = zeta::active_buffer_diagnostics(&snapshot, search_range, 100);
+
+    assert_eq!(
+        active_buffer_diagnostics,
+        vec![zeta_prompt::ActiveBufferDiagnostic {
+            severity: Some(1),
+            message: "second error".to_string(),
+            snippet: text,
+            snippet_buffer_row_range: 5..5,
+            diagnostic_range_in_snippet: 61..73,
+        }]
+    );
+
+    let buffer = cx.new(|cx| {
+        Buffer::local(
+            indoc! {"
+                one
+                two
+                three
+                four
+                five
+            "},
+            cx,
+        )
+    });
+
+    buffer.update(cx, |buffer, cx| {
+        let snapshot = buffer.snapshot();
+        let diagnostics = DiagnosticSet::new(
+            vec![
+                DiagnosticEntry {
+                    range: text::PointUtf16::new(0, 0)..text::PointUtf16::new(0, 3),
+                    diagnostic: Diagnostic {
+                        severity: DiagnosticSeverity::ERROR,
+                        message: "row zero".to_string(),
+                        group_id: 1,
+                        is_primary: true,
+                        source_kind: language::DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
+                    },
+                },
+                DiagnosticEntry {
+                    range: text::PointUtf16::new(2, 0)..text::PointUtf16::new(2, 5),
+                    diagnostic: Diagnostic {
+                        severity: DiagnosticSeverity::WARNING,
+                        message: "row two".to_string(),
+                        group_id: 2,
+                        is_primary: true,
+                        source_kind: language::DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
+                    },
+                },
+                DiagnosticEntry {
+                    range: text::PointUtf16::new(4, 0)..text::PointUtf16::new(4, 4),
+                    diagnostic: Diagnostic {
+                        severity: DiagnosticSeverity::INFORMATION,
+                        message: "row four".to_string(),
+                        group_id: 3,
+                        is_primary: true,
+                        source_kind: language::DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
+                    },
+                },
+            ],
+            &snapshot,
+        );
+        buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx);
+    });
+
+    let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
+
+    let active_buffer_diagnostics =
+        zeta::active_buffer_diagnostics(&snapshot, Point::new(2, 0)..Point::new(4, 0), 100);
+
+    assert_eq!(
+        active_buffer_diagnostics
+            .iter()
+            .map(|diagnostic| (
+                diagnostic.severity,
+                diagnostic.message.clone(),
+                diagnostic.snippet.clone(),
+                diagnostic.snippet_buffer_row_range.clone(),
+                diagnostic.diagnostic_range_in_snippet.clone(),
+            ))
+            .collect::<Vec<_>>(),
+        vec![
+            (
+                Some(2),
+                "row two".to_string(),
+                "one\ntwo\nthree\nfour\nfive\n".to_string(),
+                2..2,
+                8..13,
+            ),
+            (
+                Some(3),
+                "row four".to_string(),
+                "one\ntwo\nthree\nfour\nfive\n".to_string(),
+                4..4,
+                19..23,
+            ),
+        ]
+    );
+}
 
 // Generate a model response that would apply the given diff to the active file.
 fn model_response(request: &PredictEditsV3Request, diff_to_apply: &str) -> PredictEditsV3Response {
@@ -1850,9 +2178,8 @@ fn init_test_with_fake_client(
         let client = client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx);
         client.cloud_client().set_credentials(1, "test".into());
 
-        language_model::init(client.clone(), cx);
-
         let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
+        language_model::init(user_store.clone(), client.clone(), cx);
         let ep_store = EditPredictionStore::global(&client, &user_store, cx);
 
         (
@@ -1886,11 +2213,13 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) {
         inputs: ZetaPromptInput {
             events: Default::default(),
             related_files: Default::default(),
+            active_buffer_diagnostics: vec![],
             cursor_path: Path::new("").into(),
             cursor_excerpt: "".into(),
             cursor_offset_in_excerpt: 0,
             excerpt_start_row: None,
             excerpt_ranges: Default::default(),
+            syntax_ranges: None,
             experiment: None,
             in_open_source_repo: false,
             can_collect_data: false,
@@ -2218,8 +2547,9 @@ async fn make_test_ep_store(
     });
 
     let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx));
+    let user_store = cx.update(|cx| cx.new(|cx| client::UserStore::new(client.clone(), cx)));
     cx.update(|cx| {
-        RefreshLlmTokenListener::register(client.clone(), cx);
+        RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
     });
     let _server = FakeServer::for_client(42, &client, cx).await;
 
@@ -2301,8 +2631,9 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut
 
     let client =
         cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx));
+    let user_store = cx.update(|cx| cx.new(|cx| client::UserStore::new(client.clone(), cx)));
     cx.update(|cx| {
-        language_model::RefreshLlmTokenListener::register(client.clone(), cx);
+        language_model::RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
     });
 
     let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx));
@@ -2335,74 +2666,6 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut
     );
 }
 
-#[gpui::test]
-fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) {
-    let buffer = cx.new(|cx| {
-        Buffer::local(
-            indoc! {"
-                zero
-                one
-                two
-                three
-                four
-                five
-                six
-                seven
-                eight
-                nine
-                ten
-                eleven
-                twelve
-                thirteen
-                fourteen
-                fifteen
-                sixteen
-                seventeen
-                eighteen
-                nineteen
-                twenty
-                twenty-one
-                twenty-two
-                twenty-three
-                twenty-four
-            "},
-            cx,
-        )
-    });
-
-    let old_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
-
-    buffer.update(cx, |buffer, cx| {
-        let point = Point::new(12, 0);
-        buffer.edit([(point..point, "SECOND INSERTION\n")], None, cx);
-        let point = Point::new(8, 0);
-        buffer.edit([(point..point, "FIRST INSERTION\n")], None, cx);
-    });
-
-    let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
-
-    let (diff, _) = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap();
-
-    assert_eq!(
-        diff,
-        indoc! {"
-            @@ -6,10 +6,12 @@
-             five
-             six
-             seven
-            +FIRST INSERTION
-             eight
-             nine
-             ten
-             eleven
-            +SECOND INSERTION
-             twelve
-             thirteen
-             fourteen
-            "}
-    );
-}
-
 #[gpui::test]
 async fn test_diagnostic_jump_excludes_collaborator_regions(cx: &mut TestAppContext) {
     fn set_collaborator_cursor(buffer: &Entity<Buffer>, row: u32, cx: &mut TestAppContext) {

crates/edit_prediction/src/fim.rs 🔗

@@ -6,12 +6,12 @@ use crate::{
 use anyhow::{Context as _, Result, anyhow};
 use gpui::{App, AppContext as _, Entity, Task};
 use language::{
-    Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, ToOffset, ToPoint as _,
+    Anchor, Buffer, BufferSnapshot, ToOffset, ToPoint as _,
     language_settings::all_language_settings,
 };
 use settings::EditPredictionPromptFormat;
 use std::{path::Path, sync::Arc, time::Instant};
-use zeta_prompt::ZetaPromptInput;
+use zeta_prompt::{ZetaPromptInput, compute_editable_and_context_ranges};
 
 const FIM_CONTEXT_TOKENS: usize = 512;
 
@@ -62,34 +62,43 @@ pub fn request_prediction(
     let api_key = load_open_ai_compatible_api_key_if_needed(provider, cx);
 
     let result = cx.background_spawn(async move {
-        let (excerpt_range, _) = cursor_excerpt::editable_and_context_ranges_for_cursor_position(
-            cursor_point,
-            &snapshot,
+        let cursor_offset = cursor_point.to_offset(&snapshot);
+        let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
+            cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset);
+        let cursor_excerpt: Arc<str> = snapshot
+            .text_for_range(excerpt_point_range.clone())
+            .collect::<String>()
+            .into();
+        let syntax_ranges =
+            cursor_excerpt::compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range);
+        let (editable_range, _) = compute_editable_and_context_ranges(
+            &cursor_excerpt,
+            cursor_offset_in_excerpt,
+            &syntax_ranges,
             FIM_CONTEXT_TOKENS,
             0,
         );
-        let excerpt_offset_range = excerpt_range.to_offset(&snapshot);
-        let cursor_offset = cursor_point.to_offset(&snapshot);
 
         let inputs = ZetaPromptInput {
             events,
-            related_files: Vec::new(),
+            related_files: Some(Vec::new()),
+            active_buffer_diagnostics: Vec::new(),
             cursor_offset_in_excerpt: cursor_offset - excerpt_offset_range.start,
             cursor_path: full_path.clone(),
-            excerpt_start_row: Some(excerpt_range.start.row),
-            cursor_excerpt: snapshot
-                .text_for_range(excerpt_range)
-                .collect::<String>()
-                .into(),
+            excerpt_start_row: Some(excerpt_point_range.start.row),
+            cursor_excerpt,
             excerpt_ranges: Default::default(),
+            syntax_ranges: None,
             experiment: None,
             in_open_source_repo: false,
             can_collect_data: false,
             repo_url: None,
         };
 
-        let prefix = inputs.cursor_excerpt[..inputs.cursor_offset_in_excerpt].to_string();
-        let suffix = inputs.cursor_excerpt[inputs.cursor_offset_in_excerpt..].to_string();
+        let editable_text = &inputs.cursor_excerpt[editable_range.clone()];
+        let cursor_in_editable = cursor_offset_in_excerpt.saturating_sub(editable_range.start);
+        let prefix = editable_text[..cursor_in_editable].to_string();
+        let suffix = editable_text[cursor_in_editable..].to_string();
         let prompt = format_fim_prompt(prompt_format, &prefix, &suffix);
         let stop_tokens = get_fim_stop_tokens();
 

crates/edit_prediction/src/mercury.rs 🔗

@@ -1,40 +1,47 @@
 use crate::{
     DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
-    EditPredictionStartedDebugEvent, open_ai_response::text_from_response,
+    EditPredictionStartedDebugEvent, EditPredictionStore, open_ai_response::text_from_response,
     prediction::EditPredictionResult, zeta::compute_edits,
 };
 use anyhow::{Context as _, Result};
 use cloud_llm_client::EditPredictionRejectReason;
 use futures::AsyncReadExt as _;
 use gpui::{
-    App, AppContext as _, Entity, Global, SharedString, Task,
-    http_client::{self, AsyncBody, HttpClient, Method},
+    App, AppContext as _, Context, Entity, Global, SharedString, Task,
+    http_client::{self, AsyncBody, HttpClient, Method, StatusCode},
 };
-use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
+use language::{ToOffset, ToPoint as _};
 use language_model::{ApiKeyState, EnvVar, env_var};
 use release_channel::AppVersion;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant};
-
-use zeta_prompt::{ExcerptRanges, ZetaPromptInput};
+use zeta_prompt::ZetaPromptInput;
 
 const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions";
-const MAX_REWRITE_TOKENS: usize = 150;
-const MAX_CONTEXT_TOKENS: usize = 350;
 
 pub struct Mercury {
     pub api_token: Entity<ApiKeyState>,
+    payment_required_error: bool,
 }
 
 impl Mercury {
     pub fn new(cx: &mut App) -> Self {
         Mercury {
             api_token: mercury_api_token(cx),
+            payment_required_error: false,
         }
     }
 
+    pub fn has_payment_required_error(&self) -> bool {
+        self.payment_required_error
+    }
+
+    pub fn set_payment_required_error(&mut self, payment_required_error: bool) {
+        self.payment_required_error = payment_required_error;
+    }
+
     pub(crate) fn request_prediction(
-        &self,
+        &mut self,
         EditPredictionModelInput {
             buffer,
             snapshot,
@@ -44,7 +51,7 @@ impl Mercury {
             debug_tx,
             ..
         }: EditPredictionModelInput,
-        cx: &mut App,
+        cx: &mut Context<EditPredictionStore>,
     ) -> Task<Result<Option<EditPredictionResult>>> {
         self.api_token.update(cx, |key_state, cx| {
             _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx);
@@ -64,52 +71,47 @@ impl Mercury {
         let active_buffer = buffer.clone();
 
         let result = cx.background_spawn(async move {
-            let (editable_range, context_range) =
-                crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position(
-                    cursor_point,
-                    &snapshot,
-                    MAX_CONTEXT_TOKENS,
-                    MAX_REWRITE_TOKENS,
-                );
+            let cursor_offset = cursor_point.to_offset(&snapshot);
+            let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
+                crate::cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset);
 
-            let related_files = crate::filter_redundant_excerpts(
+            let related_files = zeta_prompt::filter_redundant_excerpts(
                 related_files,
                 full_path.as_ref(),
-                context_range.start.row..context_range.end.row,
+                excerpt_point_range.start.row..excerpt_point_range.end.row,
             );
 
-            let context_offset_range = context_range.to_offset(&snapshot);
-            let context_start_row = context_range.start.row;
-
-            let editable_offset_range = editable_range.to_offset(&snapshot);
+            let cursor_excerpt: Arc<str> = snapshot
+                .text_for_range(excerpt_point_range.clone())
+                .collect::<String>()
+                .into();
+            let syntax_ranges = crate::cursor_excerpt::compute_syntax_ranges(
+                &snapshot,
+                cursor_offset,
+                &excerpt_offset_range,
+            );
+            let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges(
+                &cursor_excerpt,
+                cursor_offset_in_excerpt,
+                &syntax_ranges,
+            );
 
-            let editable_range_in_excerpt = (editable_offset_range.start
-                - context_offset_range.start)
-                ..(editable_offset_range.end - context_offset_range.start);
-            let context_range_in_excerpt =
-                0..(context_offset_range.end - context_offset_range.start);
+            let editable_offset_range = (excerpt_offset_range.start
+                + excerpt_ranges.editable_350.start)
+                ..(excerpt_offset_range.start + excerpt_ranges.editable_350.end);
 
             let inputs = zeta_prompt::ZetaPromptInput {
                 events,
-                related_files,
+                related_files: Some(related_files),
                 cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot)
-                    - context_offset_range.start,
+                    - excerpt_offset_range.start,
                 cursor_path: full_path.clone(),
-                cursor_excerpt: snapshot
-                    .text_for_range(context_range)
-                    .collect::<String>()
-                    .into(),
+                cursor_excerpt,
                 experiment: None,
-                excerpt_start_row: Some(context_start_row),
-                excerpt_ranges: ExcerptRanges {
-                    editable_150: editable_range_in_excerpt.clone(),
-                    editable_180: editable_range_in_excerpt.clone(),
-                    editable_350: editable_range_in_excerpt.clone(),
-                    editable_150_context_350: context_range_in_excerpt.clone(),
-                    editable_180_context_350: context_range_in_excerpt.clone(),
-                    editable_350_context_150: context_range_in_excerpt.clone(),
-                    ..Default::default()
-                },
+                excerpt_start_row: Some(excerpt_point_range.start.row),
+                excerpt_ranges,
+                syntax_ranges: Some(syntax_ranges),
+                active_buffer_diagnostics: vec![],
                 in_open_source_repo: false,
                 can_collect_data: false,
                 repo_url: None,
@@ -171,6 +173,12 @@ impl Mercury {
 
             let response_received_at = Instant::now();
             if !response.status().is_success() {
+                if response.status() == StatusCode::PAYMENT_REQUIRED {
+                    anyhow::bail!(MercuryPaymentRequiredError(
+                        mercury_payment_required_message(&body),
+                    ));
+                }
+
                 anyhow::bail!(
                     "Request failed with status: {:?}\nBody: {}",
                     response.status(),
@@ -217,9 +225,22 @@ impl Mercury {
             anyhow::Ok((id, edits, snapshot, response_received_at, inputs))
         });
 
-        cx.spawn(async move |cx| {
-            let (id, edits, old_snapshot, response_received_at, inputs) =
-                result.await.context("Mercury edit prediction failed")?;
+        cx.spawn(async move |ep_store, cx| {
+            let result = result.await.context("Mercury edit prediction failed");
+
+            let has_payment_required_error = result
+                .as_ref()
+                .err()
+                .is_some_and(is_mercury_payment_required_error);
+
+            ep_store.update(cx, |store, cx| {
+                store
+                    .mercury
+                    .set_payment_required_error(has_payment_required_error);
+                cx.notify();
+            })?;
+
+            let (id, edits, old_snapshot, response_received_at, inputs) = result?;
             anyhow::Ok(Some(
                 EditPredictionResult::new(
                     EditPredictionId(id.into()),
@@ -260,7 +281,7 @@ fn build_prompt(inputs: &ZetaPromptInput) -> String {
         &mut prompt,
         RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END,
         |prompt| {
-            for related_file in inputs.related_files.iter() {
+            for related_file in inputs.related_files.as_deref().unwrap_or_default().iter() {
                 for related_excerpt in &related_file.excerpts {
                     push_delimited(
                         prompt,
@@ -323,6 +344,33 @@ fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce(
 pub const MERCURY_CREDENTIALS_URL: SharedString =
     SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
 pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
+
+#[derive(Debug, thiserror::Error)]
+#[error("{0}")]
+struct MercuryPaymentRequiredError(SharedString);
+
+#[derive(Deserialize)]
+struct MercuryErrorResponse {
+    error: MercuryErrorMessage,
+}
+
+#[derive(Deserialize)]
+struct MercuryErrorMessage {
+    message: String,
+}
+
+fn is_mercury_payment_required_error(error: &anyhow::Error) -> bool {
+    error
+        .downcast_ref::<MercuryPaymentRequiredError>()
+        .is_some()
+}
+
+fn mercury_payment_required_message(body: &[u8]) -> SharedString {
+    serde_json::from_slice::<MercuryErrorResponse>(body)
+        .map(|response| response.error.message.into())
+        .unwrap_or_else(|_| String::from_utf8_lossy(body).trim().to_string().into())
+}
+
 pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
 
 struct GlobalMercuryApiKey(Entity<ApiKeyState>);

crates/edit_prediction/src/prediction.rs 🔗

@@ -156,12 +156,14 @@ mod tests {
             model_version: None,
             inputs: ZetaPromptInput {
                 events: vec![],
-                related_files: vec![],
+                related_files: Some(vec![]),
+                active_buffer_diagnostics: vec![],
                 cursor_path: Path::new("path.txt").into(),
                 cursor_offset_in_excerpt: 0,
                 cursor_excerpt: "".into(),
                 excerpt_start_row: None,
                 excerpt_ranges: Default::default(),
+                syntax_ranges: None,
                 experiment: None,
                 in_open_source_repo: false,
                 can_collect_data: false,

crates/edit_prediction/src/sweep_ai.rs 🔗

@@ -212,7 +212,8 @@ impl SweepAi {
 
             let ep_inputs = zeta_prompt::ZetaPromptInput {
                 events: inputs.events,
-                related_files: inputs.related_files.clone(),
+                related_files: Some(inputs.related_files.clone()),
+                active_buffer_diagnostics: vec![],
                 cursor_path: full_path.clone(),
                 cursor_excerpt: request_body.file_contents.clone().into(),
                 cursor_offset_in_excerpt: request_body.cursor_position,
@@ -226,6 +227,7 @@ impl SweepAi {
                     editable_350_context_150: 0..inputs.snapshot.len(),
                     ..Default::default()
                 },
+                syntax_ranges: None,
                 experiment: None,
                 in_open_source_repo: false,
                 can_collect_data: false,

crates/edit_prediction/src/zeta.rs 🔗

@@ -1,24 +1,31 @@
-use crate::cursor_excerpt::compute_excerpt_ranges;
-use crate::prediction::EditPredictionResult;
 use crate::{
     CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId,
     EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, StoredEvent,
+    ZedUpdateRequiredError,
+    cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges},
+    prediction::EditPredictionResult,
 };
 use anyhow::Result;
-use cloud_llm_client::predict_edits_v3::RawCompletionRequest;
-use cloud_llm_client::{AcceptEditPredictionBody, EditPredictionRejectReason};
+use cloud_llm_client::{
+    AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
+};
 use edit_prediction_types::PredictedCursorPosition;
-use gpui::{App, AppContext as _, Task, prelude::*};
-use language::language_settings::all_language_settings;
-use language::{BufferSnapshot, ToOffset as _, ToPoint, text_diff};
+use gpui::{App, AppContext as _, Entity, Task, WeakEntity, prelude::*};
+use language::{
+    Buffer, BufferSnapshot, DiagnosticSeverity, OffsetRangeExt as _, ToOffset as _,
+    language_settings::all_language_settings, text_diff,
+};
 use release_channel::AppVersion;
 use settings::EditPredictionPromptFormat;
-use text::{Anchor, Bias};
+use text::{Anchor, Bias, Point};
+use ui::SharedString;
+use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
+use zeta_prompt::{ParsedOutput, ZetaPromptInput};
 
 use std::{env, ops::Range, path::Path, sync::Arc, time::Instant};
 use zeta_prompt::{
-    CURSOR_MARKER, ZetaFormat, clean_zeta2_model_output, format_zeta_prompt, get_prefill,
-    output_with_context_for_format, prompt_input_contains_special_tokens,
+    CURSOR_MARKER, ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output,
+    prompt_input_contains_special_tokens, stop_tokens_for_format,
     zeta1::{self, EDITABLE_REGION_END_MARKER},
 };
 
@@ -37,6 +44,7 @@ pub fn request_prediction_with_zeta(
         debug_tx,
         trigger,
         project,
+        diagnostic_search_range,
         can_collect_data,
         is_open_source,
         ..
@@ -86,6 +94,17 @@ pub fn request_prediction_with_zeta(
         .map(|organization| organization.id.clone());
     let app_version = AppVersion::global(cx);
 
+    struct Prediction {
+        prompt_input: ZetaPromptInput,
+        buffer: Entity<Buffer>,
+        snapshot: BufferSnapshot,
+        edits: Vec<(Range<Anchor>, Arc<str>)>,
+        cursor_position: Option<PredictedCursorPosition>,
+        received_response_at: Instant,
+        editable_range_in_buffer: Range<usize>,
+        model_version: Option<String>,
+    }
+
     let request_task = cx.background_spawn({
         async move {
             let zeta_version = raw_config
@@ -94,11 +113,11 @@ pub fn request_prediction_with_zeta(
                 .unwrap_or(ZetaFormat::default());
 
             let cursor_offset = position.to_offset(&snapshot);
-            let editable_range_in_excerpt: Range<usize>;
             let (full_context_offset_range, prompt_input) = zeta2_prompt_input(
                 &snapshot,
                 related_files,
                 events,
+                diagnostic_search_range,
                 excerpt_path,
                 cursor_offset,
                 preferred_experiment,
@@ -108,7 +127,7 @@ pub fn request_prediction_with_zeta(
             );
 
             if prompt_input_contains_special_tokens(&prompt_input, zeta_version) {
-                return Ok((None, None));
+                return Err(anyhow::anyhow!("prompt contains special tokens"));
             }
 
             if let Some(debug_tx) = &debug_tx {
@@ -126,19 +145,19 @@ pub fn request_prediction_with_zeta(
 
             log::trace!("Sending edit prediction request");
 
-            let (request_id, output_text, model_version, usage) =
+            let (request_id, output, model_version, usage) =
                 if let Some(custom_settings) = &custom_server_settings {
                     let max_tokens = custom_settings.max_output_tokens * 4;
 
                     match custom_settings.prompt_format {
                         EditPredictionPromptFormat::Zeta => {
                             let ranges = &prompt_input.excerpt_ranges;
+                            let editable_range_in_excerpt = ranges.editable_350.clone();
                             let prompt = zeta1::format_zeta1_from_input(
                                 &prompt_input,
-                                ranges.editable_350.clone(),
+                                editable_range_in_excerpt.clone(),
                                 ranges.editable_350_context_150.clone(),
                             );
-                            editable_range_in_excerpt = ranges.editable_350.clone();
                             let stop_tokens = vec![
                                 EDITABLE_REGION_END_MARKER.to_string(),
                                 format!("{EDITABLE_REGION_END_MARKER}\n"),
@@ -159,26 +178,27 @@ pub fn request_prediction_with_zeta(
 
                             let request_id = EditPredictionId(request_id.into());
                             let output_text = zeta1::clean_zeta1_model_output(&response_text);
+                            let parsed_output = output_text.map(|text| ParsedOutput {
+                                new_editable_region: text,
+                                range_in_excerpt: editable_range_in_excerpt,
+                            });
 
-                            (request_id, output_text, None, None)
+                            (request_id, parsed_output, None, None)
                         }
                         EditPredictionPromptFormat::Zeta2 => {
                             let prompt = format_zeta_prompt(&prompt_input, zeta_version);
                             let prefill = get_prefill(&prompt_input, zeta_version);
                             let prompt = format!("{prompt}{prefill}");
 
-                            editable_range_in_excerpt = zeta_prompt::excerpt_range_for_format(
-                                zeta_version,
-                                &prompt_input.excerpt_ranges,
-                            )
-                            .0;
-
                             let (response_text, request_id) = send_custom_server_request(
                                 provider,
                                 custom_settings,
                                 prompt,
                                 max_tokens,
-                                vec![],
+                                stop_tokens_for_format(zeta_version)
+                                    .iter()
+                                    .map(|token| token.to_string())
+                                    .collect(),
                                 open_ai_compatible_api_key.clone(),
                                 &http_client,
                             )
@@ -189,7 +209,11 @@ pub fn request_prediction_with_zeta(
                                 None
                             } else {
                                 let output = format!("{prefill}{response_text}");
-                                Some(clean_zeta2_model_output(&output, zeta_version).to_string())
+                                Some(parse_zeta2_model_output(
+                                    &output,
+                                    zeta_version,
+                                    &prompt_input,
+                                )?)
                             };
 
                             (request_id, output_text, None, None)
@@ -208,17 +232,14 @@ pub fn request_prediction_with_zeta(
                         model: config.model_id.clone().unwrap_or_default(),
                         prompt,
                         temperature: None,
-                        stop: vec![],
+                        stop: stop_tokens_for_format(config.format)
+                            .iter()
+                            .map(|token| std::borrow::Cow::Borrowed(*token))
+                            .collect(),
                         max_tokens: Some(2048),
                         environment,
                     };
 
-                    editable_range_in_excerpt = zeta_prompt::excerpt_range_for_format(
-                        config.format,
-                        &prompt_input.excerpt_ranges,
-                    )
-                    .1;
-
                     let (mut response, usage) = EditPredictionStore::send_raw_llm_request(
                         request,
                         client,
@@ -230,13 +251,19 @@ pub fn request_prediction_with_zeta(
                     .await?;
 
                     let request_id = EditPredictionId(response.id.clone().into());
-                    let output_text = response.choices.pop().map(|choice| {
+                    let output = if let Some(choice) = response.choices.pop() {
                         let response = &choice.text;
                         let output = format!("{prefill}{response}");
-                        clean_zeta2_model_output(&output, config.format).to_string()
-                    });
+                        Some(parse_zeta2_model_output(
+                            &output,
+                            config.format,
+                            &prompt_input,
+                        )?)
+                    } else {
+                        None
+                    };
 
-                    (request_id, output_text, None, usage)
+                    (request_id, output, None, usage)
                 } else {
                     // Use V3 endpoint - server handles model/version selection and suffix stripping
                     let (response, usage) = EditPredictionStore::send_v3_request(
@@ -250,23 +277,26 @@ pub fn request_prediction_with_zeta(
                     .await?;
 
                     let request_id = EditPredictionId(response.request_id.into());
-                    let output_text = if response.output.is_empty() {
-                        None
-                    } else {
-                        Some(response.output)
-                    };
-                    editable_range_in_excerpt = response.editable_range;
+                    let output_text = Some(response.output).filter(|s| !s.is_empty());
                     let model_version = response.model_version;
+                    let parsed_output = ParsedOutput {
+                        new_editable_region: output_text.unwrap_or_default(),
+                        range_in_excerpt: response.editable_range,
+                    };
 
-                    (request_id, output_text, model_version, usage)
+                    (request_id, Some(parsed_output), model_version, usage)
                 };
 
             let received_response_at = Instant::now();
 
             log::trace!("Got edit prediction response");
 
-            let Some(mut output_text) = output_text else {
-                return Ok((Some((request_id, None, model_version)), usage));
+            let Some(ParsedOutput {
+                new_editable_region: mut output_text,
+                range_in_excerpt: editable_range_in_excerpt,
+            }) = output
+            else {
+                return Ok(((request_id, None), None));
             };
 
             let editable_range_in_buffer = editable_range_in_excerpt.start
@@ -277,17 +307,6 @@ pub fn request_prediction_with_zeta(
                 .text_for_range(editable_range_in_buffer.clone())
                 .collect::<String>();
 
-            // For the hashline format, the model may return <|set|>/<|insert|>
-            // edit commands instead of a full replacement. Apply them against
-            // the original editable region to produce the full replacement text.
-            // This must happen before cursor marker stripping because the cursor
-            // marker is embedded inside edit command content.
-            if let Some(rewritten_output) =
-                output_with_context_for_format(zeta_version, &old_text, &output_text)?
-            {
-                output_text = rewritten_output;
-            }
-
             // Client-side cursor marker processing (applies to both raw and v3 responses)
             let cursor_offset_in_output = output_text.find(CURSOR_MARKER);
             if let Some(offset) = cursor_offset_in_output {
@@ -323,40 +342,37 @@ pub fn request_prediction_with_zeta(
             );
 
             anyhow::Ok((
-                Some((
+                (
                     request_id,
-                    Some((
+                    Some(Prediction {
                         prompt_input,
                         buffer,
-                        snapshot.clone(),
+                        snapshot: snapshot.clone(),
                         edits,
                         cursor_position,
                         received_response_at,
                         editable_range_in_buffer,
-                    )),
-                    model_version,
-                )),
+                        model_version,
+                    }),
+                ),
                 usage,
             ))
         }
     });
 
     cx.spawn(async move |this, cx| {
-        let Some((id, prediction, model_version)) =
-            EditPredictionStore::handle_api_response(&this, request_task.await, cx)?
-        else {
-            return Ok(None);
-        };
+        let (id, prediction) = handle_api_response(&this, request_task.await, cx)?;
 
-        let Some((
-            inputs,
-            edited_buffer,
-            edited_buffer_snapshot,
+        let Some(Prediction {
+            prompt_input: inputs,
+            buffer: edited_buffer,
+            snapshot: edited_buffer_snapshot,
             edits,
             cursor_position,
             received_response_at,
             editable_range_in_buffer,
-        )) = prediction
+            model_version,
+        }) = prediction
         else {
             return Ok(Some(EditPredictionResult {
                 id,
@@ -423,10 +439,93 @@ pub fn request_prediction_with_zeta(
     })
 }
 
+fn handle_api_response<T>(
+    this: &WeakEntity<EditPredictionStore>,
+    response: Result<(T, Option<client::EditPredictionUsage>)>,
+    cx: &mut gpui::AsyncApp,
+) -> Result<T> {
+    match response {
+        Ok((data, usage)) => {
+            if let Some(usage) = usage {
+                this.update(cx, |this, cx| {
+                    this.user_store.update(cx, |user_store, cx| {
+                        user_store.update_edit_prediction_usage(usage, cx);
+                    });
+                })
+                .ok();
+            }
+            Ok(data)
+        }
+        Err(err) => {
+            if err.is::<ZedUpdateRequiredError>() {
+                cx.update(|cx| {
+                    this.update(cx, |this, _cx| {
+                        this.update_required = true;
+                    })
+                    .ok();
+
+                    let error_message: SharedString = err.to_string().into();
+                    show_app_notification(
+                        NotificationId::unique::<ZedUpdateRequiredError>(),
+                        cx,
+                        move |cx| {
+                            cx.new(|cx| {
+                                ErrorMessagePrompt::new(error_message.clone(), cx)
+                                    .with_link_button("Update Zed", "https://zed.dev/releases")
+                            })
+                        },
+                    );
+                });
+            }
+            Err(err)
+        }
+    }
+}
+
+pub(crate) fn active_buffer_diagnostics(
+    snapshot: &language::BufferSnapshot,
+    diagnostic_search_range: Range<Point>,
+    additional_context_token_count: usize,
+) -> Vec<zeta_prompt::ActiveBufferDiagnostic> {
+    snapshot
+        .diagnostics_in_range::<Point, Point>(diagnostic_search_range, false)
+        .map(|entry| {
+            let severity = match entry.diagnostic.severity {
+                DiagnosticSeverity::ERROR => Some(1),
+                DiagnosticSeverity::WARNING => Some(2),
+                DiagnosticSeverity::INFORMATION => Some(3),
+                DiagnosticSeverity::HINT => Some(4),
+                _ => None,
+            };
+            let diagnostic_point_range = entry.range.clone();
+            let snippet_point_range = cursor_excerpt::expand_context_syntactically_then_linewise(
+                snapshot,
+                diagnostic_point_range.clone(),
+                additional_context_token_count,
+            );
+            let snippet = snapshot
+                .text_for_range(snippet_point_range.clone())
+                .collect::<String>();
+            let snippet_start_offset = snippet_point_range.start.to_offset(snapshot);
+            let diagnostic_offset_range = diagnostic_point_range.to_offset(snapshot);
+            zeta_prompt::ActiveBufferDiagnostic {
+                severity,
+                message: entry.diagnostic.message.clone(),
+                snippet,
+                snippet_buffer_row_range: diagnostic_point_range.start.row
+                    ..diagnostic_point_range.end.row,
+                diagnostic_range_in_snippet: diagnostic_offset_range.start - snippet_start_offset
+                    ..diagnostic_offset_range.end - snippet_start_offset,
+            }
+        })
+        .collect()
+}
+
 pub fn zeta2_prompt_input(
     snapshot: &language::BufferSnapshot,
     related_files: Vec<zeta_prompt::RelatedFile>,
     events: Vec<Arc<zeta_prompt::Event>>,
+    diagnostic_search_range: Range<Point>,
     excerpt_path: Arc<Path>,
     cursor_offset: usize,
     preferred_experiment: Option<String>,
@@ -434,39 +533,39 @@ pub fn zeta2_prompt_input(
     can_collect_data: bool,
     repo_url: Option<String>,
 ) -> (Range<usize>, zeta_prompt::ZetaPromptInput) {
-    let cursor_point = cursor_offset.to_point(snapshot);
-
-    let (full_context, full_context_offset_range, excerpt_ranges) =
-        compute_excerpt_ranges(cursor_point, snapshot);
-
-    let related_files = crate::filter_redundant_excerpts(
-        related_files,
-        excerpt_path.as_ref(),
-        full_context.start.row..full_context.end.row,
+    let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
+        compute_cursor_excerpt(snapshot, cursor_offset);
+
+    let cursor_excerpt: Arc<str> = snapshot
+        .text_for_range(excerpt_point_range.clone())
+        .collect::<String>()
+        .into();
+    let syntax_ranges = compute_syntax_ranges(snapshot, cursor_offset, &excerpt_offset_range);
+    let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges(
+        &cursor_excerpt,
+        cursor_offset_in_excerpt,
+        &syntax_ranges,
     );
 
-    let full_context_start_offset = full_context_offset_range.start;
-    let full_context_start_row = full_context.start.row;
-
-    let cursor_offset_in_excerpt = cursor_offset - full_context_start_offset;
+    let active_buffer_diagnostics =
+        active_buffer_diagnostics(snapshot, diagnostic_search_range, 100);
 
     let prompt_input = zeta_prompt::ZetaPromptInput {
         cursor_path: excerpt_path,
-        cursor_excerpt: snapshot
-            .text_for_range(full_context)
-            .collect::<String>()
-            .into(),
+        cursor_excerpt,
         cursor_offset_in_excerpt,
-        excerpt_start_row: Some(full_context_start_row),
+        excerpt_start_row: Some(excerpt_point_range.start.row),
         events,
-        related_files,
+        related_files: Some(related_files),
+        active_buffer_diagnostics,
         excerpt_ranges,
+        syntax_ranges: Some(syntax_ranges),
         experiment: preferred_experiment,
         in_open_source_repo: is_open_source,
         can_collect_data,
         repo_url,
     };
-    (full_context_offset_range, prompt_input)
+    (excerpt_offset_range, prompt_input)
 }
 
 pub(crate) fn edit_prediction_accepted(

crates/edit_prediction_cli/src/format_prompt.rs 🔗

@@ -13,7 +13,7 @@ use std::ops::Range;
 use std::sync::Arc;
 use zeta_prompt::{
     ZetaFormat, encode_patch_as_output_for_format, excerpt_range_for_format, format_zeta_prompt,
-    output_end_marker_for_format, resolve_cursor_region,
+    multi_region, output_end_marker_for_format, resolve_cursor_region,
 };
 
 pub async fn run_format_prompt(
@@ -49,6 +49,24 @@ pub async fn run_format_prompt(
                 provider: args.provider,
             });
         }
+        PredictionProvider::TeacherMultiRegion(_)
+        | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
+            step_progress.set_substatus("formatting teacher multi-region prompt");
+
+            let zeta_format = ZetaFormat::default();
+            let (editable_range, context_range) =
+                excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges);
+
+            let prompt =
+                TeacherMultiRegionPrompt::format_prompt(example, editable_range, context_range);
+            example.prompt = Some(ExamplePrompt {
+                input: prompt,
+                expected_output: String::new(),
+                rejected_output: None,
+                prefill: None,
+                provider: args.provider,
+            });
+        }
         PredictionProvider::Zeta2(zeta_format) => {
             step_progress.set_substatus("formatting zeta2 prompt");
 
@@ -95,7 +113,7 @@ pub fn zeta2_output_for_patch(
     cursor_offset: Option<usize>,
     version: ZetaFormat,
 ) -> Result<String> {
-    let (context, editable_range, _) = resolve_cursor_region(input, version);
+    let (context, editable_range, _, _) = resolve_cursor_region(input, version);
     let mut old_editable_region = context[editable_range].to_string();
 
     if !old_editable_region.ends_with_newline() {
@@ -108,7 +126,7 @@ pub fn zeta2_output_for_patch(
         return Ok(encoded_output);
     }
 
-    let (mut result, first_hunk_offset) =
+    let (result, first_hunk_offset) =
         udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable_region).with_context(
             || {
                 format!(
@@ -118,6 +136,22 @@ pub fn zeta2_output_for_patch(
             },
         )?;
 
+    if version == ZetaFormat::V0306SeedMultiRegions {
+        let cursor_in_new = cursor_offset.map(|cursor_offset| {
+            let hunk_start = first_hunk_offset.unwrap_or(0);
+            result.floor_char_boundary((hunk_start + cursor_offset).min(result.len()))
+        });
+        return multi_region::encode_from_old_and_new(
+            &old_editable_region,
+            &result,
+            cursor_in_new,
+            zeta_prompt::CURSOR_MARKER,
+            zeta_prompt::seed_coder::END_MARKER,
+            zeta_prompt::seed_coder::NO_EDITS,
+        );
+    }
+
+    let mut result = result;
     if let Some(cursor_offset) = cursor_offset {
         // The cursor_offset is relative to the start of the hunk's new text (context + additions).
         // We need to add where the hunk context matched in the editable region to compute
@@ -211,7 +245,6 @@ impl TeacherPrompt {
             .context("editable region not found in prompt content")?;
         let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count();
 
-        // Use full context so cursor offset (relative to editable region start) aligns with diff content
         let editable_region_lines = old_editable_region.lines().count() as u32;
         let diff = language::unified_diff_with_context(
             &old_editable_region,
@@ -259,7 +292,11 @@ impl TeacherPrompt {
     }
 
     pub fn format_context(example: &Example) -> String {
-        let related_files = example.prompt_inputs.as_ref().map(|pi| &pi.related_files);
+        let related_files = example
+            .prompt_inputs
+            .as_ref()
+            .and_then(|pi| pi.related_files.as_deref());
+
         let Some(related_files) = related_files else {
             return "(No context)".to_string();
         };
@@ -314,6 +351,202 @@ impl TeacherPrompt {
     }
 }
 
+pub struct TeacherMultiRegionPrompt;
+
+impl TeacherMultiRegionPrompt {
+    pub(crate) const USER_CURSOR_MARKER: &str = "<|user_cursor|>";
+    pub(crate) const NO_EDITS: &str = "NO_EDITS";
+
+    /// Truncate edit history to this number of last lines
+    const MAX_HISTORY_LINES: usize = 128;
+
+    pub fn format_prompt(
+        example: &Example,
+        editable_range: Range<usize>,
+        context_range: Range<usize>,
+    ) -> String {
+        let edit_history = Self::format_edit_history(&example.spec.edit_history);
+        let context = Self::format_context(example);
+        let cursor_excerpt = Self::format_cursor_excerpt(example, editable_range, context_range);
+
+        let prompt_template = crate::prompt_assets::get_prompt("teacher_multi_region.md");
+        let prompt = prompt_template
+            .replace("{{context}}", &context)
+            .replace("{{edit_history}}", &edit_history)
+            .replace("{{cursor_excerpt}}", &cursor_excerpt);
+
+        prompt
+    }
+
+    pub fn parse(example: &Example, response: &str) -> Result<(String, Option<ActualCursor>)> {
+        let no_edits = (String::new(), None);
+        if let Some(last_codeblock) = extract_last_codeblock(&response) {
+            if last_codeblock.trim() == Self::NO_EDITS {
+                return Ok(no_edits);
+            }
+        }
+
+        if response.trim().ends_with(Self::NO_EDITS) {
+            return Ok(no_edits);
+        }
+
+        let prompt_inputs = example
+            .prompt_inputs
+            .as_ref()
+            .context("example is missing prompt inputs")?;
+
+        let zeta_format = ZetaFormat::default();
+        let (editable_range, _) =
+            excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges);
+        let excerpt = prompt_inputs.cursor_excerpt.as_ref();
+        let old_editable_region = &excerpt[editable_range.clone()];
+        let marker_offsets = multi_region::compute_marker_offsets(old_editable_region);
+
+        let codeblock =
+            extract_last_codeblock(&response).context("no codeblock found in model response")?;
+        let (start_num, end_num, raw_new_span) = multi_region::extract_marker_span(&codeblock)?;
+
+        let start_idx = start_num
+            .checked_sub(1)
+            .context("marker numbers are 1-indexed")?;
+        let end_idx = end_num
+            .checked_sub(1)
+            .context("marker numbers are 1-indexed")?;
+        let start_byte = *marker_offsets
+            .get(start_idx)
+            .context("start marker number out of range")?;
+        let end_byte = *marker_offsets
+            .get(end_idx)
+            .context("end marker number out of range")?;
+
+        if start_byte > end_byte {
+            return Err(anyhow!("start marker must come before end marker"));
+        }
+
+        let cursor_in_span = raw_new_span.find(Self::USER_CURSOR_MARKER);
+        let new_span = raw_new_span.replace(Self::USER_CURSOR_MARKER, "");
+
+        let old_span = &old_editable_region[start_byte..end_byte];
+        let mut new_span = new_span;
+        if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() {
+            new_span.push('\n');
+        }
+        if !old_span.ends_with('\n') && new_span.ends_with('\n') {
+            new_span.pop();
+        }
+
+        let mut new_editable_region = String::new();
+        new_editable_region.push_str(&old_editable_region[..start_byte]);
+        new_editable_region.push_str(&new_span);
+        new_editable_region.push_str(&old_editable_region[end_byte..]);
+
+        let cursor_offset = cursor_in_span.map(|pos| start_byte + pos);
+
+        if old_editable_region.starts_with('\n') && !new_editable_region.starts_with('\n') {
+            new_editable_region.insert(0, '\n');
+        }
+
+        let editable_region_offset = editable_range.start;
+        let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count();
+
+        let editable_region_lines = old_editable_region.lines().count() as u32;
+        let diff = language::unified_diff_with_context(
+            old_editable_region,
+            &new_editable_region,
+            editable_region_start_line as u32,
+            editable_region_start_line as u32,
+            editable_region_lines,
+        );
+
+        let diff = indoc::formatdoc! {"
+            --- a/{path}
+            +++ b/{path}
+            {diff}",
+            path = example.spec.cursor_path.to_string_lossy(),
+            diff = diff,
+        };
+
+        let actual_cursor = cursor_offset.map(|editable_region_cursor_offset| {
+            ActualCursor::from_editable_region(
+                &example.spec.cursor_path,
+                editable_region_cursor_offset,
+                &new_editable_region,
+                excerpt,
+                editable_region_offset,
+                editable_region_start_line,
+            )
+        });
+
+        Ok((diff, actual_cursor))
+    }
+
+    fn format_edit_history(edit_history: &str) -> String {
+        let lines: Vec<&str> = edit_history.lines().collect();
+
+        if lines.is_empty() {
+            return "(No edit history)".to_string();
+        }
+
+        if lines.len() > Self::MAX_HISTORY_LINES {
+            let truncated = lines[lines.len() - Self::MAX_HISTORY_LINES..].join("\n");
+            format!("{truncated}\n[...truncated...]")
+        } else {
+            lines.join("\n")
+        }
+    }
+
+    pub fn format_context(example: &Example) -> String {
+        let related_files = example
+            .prompt_inputs
+            .as_ref()
+            .and_then(|pi| pi.related_files.as_deref());
+        let Some(related_files) = related_files else {
+            return "(No context)".to_string();
+        };
+
+        if related_files.is_empty() {
+            return "(No context)".to_string();
+        }
+
+        let prefix = "`````";
+        let suffix = "`````\n\n";
+        let max_tokens = 1024;
+        zeta_prompt::format_related_files_within_budget(related_files, &prefix, &suffix, max_tokens)
+    }
+
+    fn format_cursor_excerpt(
+        example: &Example,
+        editable_range: Range<usize>,
+        context_range: Range<usize>,
+    ) -> String {
+        let mut result = String::new();
+
+        let prompt_inputs = example.prompt_inputs.as_ref().unwrap();
+        let excerpt = prompt_inputs.cursor_excerpt.as_ref();
+        let cursor_offset = prompt_inputs.cursor_offset_in_excerpt;
+
+        let editable_text = &excerpt[editable_range.clone()];
+        let cursor_in_editable = cursor_offset - editable_range.start;
+
+        let path_str = example.spec.cursor_path.to_string_lossy();
+        result.push_str(&format!("`````{path_str}\n"));
+
+        result.push_str(&excerpt[context_range.start..editable_range.start]);
+
+        multi_region::write_editable_with_markers(
+            &mut result,
+            editable_text,
+            cursor_in_editable,
+            Self::USER_CURSOR_MARKER,
+        );
+
+        result.push_str(&excerpt[editable_range.end..context_range.end]);
+        result.push_str("\n`````");
+
+        result
+    }
+}
+
 /// Extract the cursor excerpt from an example.
 /// First tries to extract from an existing prompt, then falls back to constructing from prompt_inputs.
 pub fn extract_cursor_excerpt_from_example(example: &Example) -> Option<String> {
@@ -458,7 +691,7 @@ mod tests {
     }
 
     #[test]
-    fn test_extract_editable_region() {
+    fn test_extract_editable_region_old_format() {
         let text = indoc::indoc! {"
             some lines
             are
@@ -480,6 +713,38 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_extract_editable_region_marker_format() {
+        let text = indoc::indoc! {"
+            some context
+            <|marker_1|>
+            one
+            two three
+            <|marker_2|>
+            more context
+            "};
+        let parsed = multi_region::extract_editable_region_from_markers(text).unwrap();
+        assert_eq!(parsed, "one\ntwo three");
+    }
+
+    #[test]
+    fn test_extract_editable_region_multi_markers() {
+        let text = indoc::indoc! {"
+            prefix
+            <|marker_1|>
+            aaa
+            bbb
+            <|marker_2|>
+            ccc
+            ddd
+            <|marker_3|>
+            suffix
+            "};
+        let parsed = multi_region::extract_editable_region_from_markers(text).unwrap();
+        // Intermediate marker and its trailing \n are stripped
+        assert_eq!(parsed, "aaa\nbbb\nccc\nddd");
+    }
+
     #[test]
     fn test_extract_last_codeblock_nested_bibtex() {
         let text = indoc::indoc! {r#"

crates/edit_prediction_cli/src/headless.rs 🔗

@@ -105,7 +105,7 @@ pub fn init(cx: &mut App) -> EpAppState {
 
     debug_adapter_extension::init(extension_host_proxy.clone(), cx);
     language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone());
-    language_model::init(client.clone(), cx);
+    language_model::init(user_store.clone(), client.clone(), cx);
     language_models::init(user_store.clone(), client.clone(), cx);
     languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx);
     prompt_store::init(cx);

crates/edit_prediction_cli/src/load_project.rs 🔗

@@ -7,12 +7,12 @@ use crate::{
 use anyhow::{Context as _, Result};
 use edit_prediction::{
     EditPredictionStore,
-    cursor_excerpt::compute_excerpt_ranges,
+    cursor_excerpt::{compute_cursor_excerpt, compute_syntax_ranges},
     udiff::{OpenedBuffers, refresh_worktree_entries, strip_diff_path_prefix},
 };
 use futures::AsyncWriteExt as _;
 use gpui::{AsyncApp, Entity};
-use language::{Anchor, Buffer, LanguageNotFound, ToOffset, ToPoint};
+use language::{Anchor, Buffer, LanguageNotFound, ToOffset};
 use project::{Project, ProjectPath, buffer_store::BufferStoreEvent};
 use std::{fs, path::PathBuf, sync::Arc};
 use zeta_prompt::ZetaPromptInput;
@@ -71,37 +71,41 @@ pub async fn run_load_project(
     let existing_related_files = example
         .prompt_inputs
         .take()
-        .map(|inputs| inputs.related_files)
-        .unwrap_or_default();
+        .and_then(|inputs| inputs.related_files);
 
     let (prompt_inputs, language_name) = buffer.read_with(&cx, |buffer, _cx| {
         let snapshot = buffer.snapshot();
-        let cursor_point = cursor_position.to_point(&snapshot);
         let cursor_offset = cursor_position.to_offset(&snapshot);
         let language_name = buffer
             .language()
             .map(|l| l.name().to_string())
             .unwrap_or_else(|| "Unknown".to_string());
 
-        let (full_context_point_range, full_context_offset_range, excerpt_ranges) =
-            compute_excerpt_ranges(cursor_point, &snapshot);
+        let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
+            compute_cursor_excerpt(&snapshot, cursor_offset);
 
         let cursor_excerpt: Arc<str> = buffer
-            .text_for_range(full_context_offset_range.clone())
+            .text_for_range(excerpt_offset_range.clone())
             .collect::<String>()
             .into();
-        let cursor_offset_in_excerpt = cursor_offset - full_context_offset_range.start;
-        let excerpt_start_row = Some(full_context_point_range.start.row);
+        let syntax_ranges = compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range);
+        let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges(
+            &cursor_excerpt,
+            cursor_offset_in_excerpt,
+            &syntax_ranges,
+        );
 
         (
             ZetaPromptInput {
                 cursor_path: example.spec.cursor_path.clone(),
                 cursor_excerpt,
                 cursor_offset_in_excerpt,
-                excerpt_start_row,
+                excerpt_start_row: Some(excerpt_point_range.start.row),
                 events,
                 related_files: existing_related_files,
+                active_buffer_diagnostics: vec![],
                 excerpt_ranges,
+                syntax_ranges: Some(syntax_ranges),
                 in_open_source_repo: false,
                 can_collect_data: false,
                 experiment: None,

crates/edit_prediction_cli/src/main.rs 🔗

@@ -360,7 +360,9 @@ enum PredictionProvider {
     Zeta2(ZetaFormat),
     Baseten(ZetaFormat),
     Teacher(TeacherBackend),
+    TeacherMultiRegion(TeacherBackend),
     TeacherNonBatching(TeacherBackend),
+    TeacherMultiRegionNonBatching(TeacherBackend),
     Repair,
 }
 
@@ -379,9 +381,15 @@ impl std::fmt::Display for PredictionProvider {
             PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"),
             PredictionProvider::Baseten(format) => write!(f, "baseten:{format}"),
             PredictionProvider::Teacher(backend) => write!(f, "teacher:{backend}"),
+            PredictionProvider::TeacherMultiRegion(backend) => {
+                write!(f, "teacher-multi-region:{backend}")
+            }
             PredictionProvider::TeacherNonBatching(backend) => {
                 write!(f, "teacher-non-batching:{backend}")
             }
+            PredictionProvider::TeacherMultiRegionNonBatching(backend) => {
+                write!(f, "teacher-multi-region-non-batching:{backend}")
+            }
             PredictionProvider::Repair => write!(f, "repair"),
         }
     }
@@ -409,13 +417,27 @@ impl std::str::FromStr for PredictionProvider {
                     .unwrap_or(TeacherBackend::default());
                 Ok(PredictionProvider::Teacher(backend))
             }
-            "teacher-non-batching" | "teacher_non_batching" | "teachernonbatching" => {
+            "teacher-multi-region" | "teacher_multi_region" => {
+                let backend = arg
+                    .map(|a| a.parse())
+                    .transpose()?
+                    .unwrap_or(TeacherBackend::default());
+                Ok(PredictionProvider::TeacherMultiRegion(backend))
+            }
+            "teacher-non-batching" | "teacher_non_batching" => {
                 let backend = arg
                     .map(|a| a.parse())
                     .transpose()?
                     .unwrap_or(TeacherBackend::default());
                 Ok(PredictionProvider::TeacherNonBatching(backend))
             }
+            "teacher-multi-region-non-batching" | "teacher_multi_region_non_batching" => {
+                let backend = arg
+                    .map(|a| a.parse())
+                    .transpose()?
+                    .unwrap_or(TeacherBackend::default());
+                Ok(PredictionProvider::TeacherMultiRegionNonBatching(backend))
+            }
             "repair" => Ok(PredictionProvider::Repair),
             "baseten" => {
                 let format = arg
@@ -426,9 +448,9 @@ impl std::str::FromStr for PredictionProvider {
             }
             _ => {
                 anyhow::bail!(
-                    "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:<version>, teacher, teacher:<backend>, teacher-non-batching, repair\n\
+                    "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:<version>, teacher, teacher:<backend>, teacher-multi-region, teacher-multi-region:<backend>, teacher-non-batching, teacher-multi-region-non-batching, repair\n\
                  For zeta2, you can optionally specify a version like `zeta2:ordered` or `zeta2:V0113_Ordered`.\n\
-                 For teacher, you can specify a backend like `teacher:sonnet46` or `teacher:gpt52`.\n\
+                 For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-multi-region:sonnet46`, `teacher-multi-region-non-batching:sonnet46`, or `teacher:gpt52`.\n\
                  Available zeta versions:\n{}",
                     ZetaFormat::options_as_string()
                 )
@@ -491,6 +513,40 @@ enum BatchProvider {
     Openai,
 }
 
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn prediction_provider_multi_region_non_batched_round_trips_to_primary_spelling() {
+        let provider: PredictionProvider = "teacher-multi-region-non-batching:sonnet46"
+            .parse()
+            .unwrap();
+        assert_eq!(
+            provider,
+            PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Sonnet46)
+        );
+        assert_eq!(
+            provider.to_string(),
+            "teacher-multi-region-non-batching:sonnet46"
+        );
+    }
+
+    #[test]
+    fn prediction_provider_multi_region_non_batched_alias_round_trips_to_primary_spelling() {
+        let provider: PredictionProvider =
+            "teacher_multi_region_non_batching:gpt52".parse().unwrap();
+        assert_eq!(
+            provider,
+            PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Gpt52)
+        );
+        assert_eq!(
+            provider.to_string(),
+            "teacher-multi-region-non-batching:gpt52"
+        );
+    }
+}
+
 impl EpArgs {
     fn output_path(&self) -> Option<PathBuf> {
         if self.in_place {
@@ -738,6 +794,21 @@ async fn load_examples(
             examples.append(&mut requested_examples);
         }
 
+        if !captured_after_timestamps.is_empty() {
+            captured_after_timestamps.sort();
+
+            let mut captured_examples = pull_examples::fetch_captured_examples_after(
+                http_client.clone(),
+                &captured_after_timestamps,
+                max_rows_per_timestamp,
+                remaining_offset,
+                background_executor.clone(),
+                Some(MIN_CAPTURE_VERSION),
+            )
+            .await?;
+            examples.append(&mut captured_examples);
+        }
+
         if !settled_after_timestamps.is_empty() {
             settled_after_timestamps.sort();
 

crates/edit_prediction_cli/src/parse_output.rs 🔗

@@ -1,16 +1,12 @@
 use crate::{
     PredictionProvider,
     example::{ActualCursor, Example},
-    format_prompt::TeacherPrompt,
+    format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt},
     repair,
 };
 use anyhow::{Context as _, Result};
 use edit_prediction::example_spec::encode_cursor_in_patch;
-use zeta_prompt::{
-    CURSOR_MARKER, ZetaFormat, clean_extracted_region_for_format,
-    current_region_markers_for_format, output_end_marker_for_format,
-    output_with_context_for_format,
-};
+use zeta_prompt::{CURSOR_MARKER, ZetaFormat, parse_zeta2_model_output};
 
 pub fn run_parse_output(example: &mut Example) -> Result<()> {
     example
@@ -45,6 +41,10 @@ pub fn parse_prediction_output(
         PredictionProvider::Teacher(_) | PredictionProvider::TeacherNonBatching(_) => {
             TeacherPrompt::parse(example, actual_output)
         }
+        PredictionProvider::TeacherMultiRegion(_)
+        | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
+            TeacherMultiRegionPrompt::parse(example, actual_output)
+        }
         PredictionProvider::Zeta2(version) => parse_zeta2_output(example, actual_output, version),
         PredictionProvider::Repair => repair::parse(example, actual_output),
         _ => anyhow::bail!(
@@ -54,43 +54,23 @@ pub fn parse_prediction_output(
     }
 }
 
-fn extract_zeta2_current_region(prompt: &str, format: ZetaFormat) -> Result<String> {
-    let (current_marker, end_marker) = current_region_markers_for_format(format);
-
-    let start = prompt.find(current_marker).with_context(|| {
-        format!(
-            "missing current marker '{}' in prompt",
-            current_marker.trim()
-        )
-    })? + current_marker.len();
-
-    let end = prompt[start..]
-        .find(end_marker)
-        .with_context(|| format!("missing end marker '{}' in prompt", end_marker.trim()))?
-        + start;
-
-    let region = &prompt[start..end];
-    let region = region.replace(CURSOR_MARKER, "");
-    Ok(clean_extracted_region_for_format(format, &region))
-}
-
 fn parse_zeta2_output(
     example: &Example,
     actual_output: &str,
     format: ZetaFormat,
 ) -> Result<(String, Option<ActualCursor>)> {
-    let prompt = &example.prompt.as_ref().context("prompt required")?.input;
     let prompt_inputs = example
         .prompt_inputs
         .as_ref()
         .context("prompt_inputs required")?;
 
-    let old_text = extract_zeta2_current_region(prompt, format)?;
+    let parsed = parse_zeta2_model_output(actual_output, format, prompt_inputs)?;
+    let range_in_excerpt = parsed.range_in_excerpt;
+
+    let excerpt = prompt_inputs.cursor_excerpt.as_ref();
+    let old_text = excerpt[range_in_excerpt.clone()].to_string();
+    let mut new_text = parsed.new_editable_region;
 
-    let mut new_text = actual_output.to_string();
-    if let Some(transformed) = output_with_context_for_format(format, &old_text, &new_text)? {
-        new_text = transformed;
-    }
     let cursor_offset = if let Some(offset) = new_text.find(CURSOR_MARKER) {
         new_text.replace_range(offset..offset + CURSOR_MARKER.len(), "");
         Some(offset)
@@ -98,14 +78,8 @@ fn parse_zeta2_output(
         None
     };
 
-    if let Some(marker) = output_end_marker_for_format(format) {
-        new_text = new_text
-            .strip_suffix(marker)
-            .unwrap_or(&new_text)
-            .to_string();
-    }
-
-    let mut old_text_normalized = old_text.clone();
+    // Normalize trailing newlines for diff generation
+    let mut old_text_normalized = old_text;
     if !new_text.is_empty() && !new_text.ends_with('\n') {
         new_text.push('\n');
     }
@@ -113,22 +87,10 @@ fn parse_zeta2_output(
         old_text_normalized.push('\n');
     }
 
-    let old_text_trimmed = old_text.trim_end_matches('\n');
-    let excerpt = prompt_inputs.cursor_excerpt.as_ref();
-    let (editable_region_offset, _) = excerpt
-        .match_indices(old_text_trimmed)
-        .min_by_key(|(index, _)| index.abs_diff(prompt_inputs.cursor_offset_in_excerpt))
-        .with_context(|| {
-            format!(
-                "could not find editable region in content.\nLooking for:\n{}\n\nIn content:\n{}",
-                old_text_trimmed, excerpt
-            )
-        })?;
-
+    let editable_region_offset = range_in_excerpt.start;
     let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count();
-
-    // Use full context so cursor offset (relative to editable region start) aligns with diff content
     let editable_region_lines = old_text_normalized.lines().count() as u32;
+
     let diff = language::unified_diff_with_context(
         &old_text_normalized,
         &new_text,
@@ -157,95 +119,3 @@ fn parse_zeta2_output(
 
     Ok((formatted_diff, actual_cursor))
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_extract_zeta2_current_region_v0113() {
-        let prompt = indoc::indoc! {"
-            <|file_sep|>src/main.rs
-            <|fim_prefix|>
-            fn main() {
-            <|fim_middle|>current
-            println!(\"hello\");
-            <|fim_suffix|>
-            }
-            <|fim_middle|>updated
-        "};
-
-        let region = extract_zeta2_current_region(prompt, ZetaFormat::V0113Ordered).unwrap();
-        assert_eq!(region, "println!(\"hello\");\n");
-    }
-
-    #[test]
-    fn test_extract_zeta2_current_region_v0112() {
-        let prompt = indoc::indoc! {"
-            <|file_sep|>src/main.rs
-            <|fim_prefix|>
-            fn main() {
-            <|fim_suffix|>
-            }
-            <|fim_middle|>current
-            println!(\"hello\");
-            <|fim_middle|>updated
-        "};
-
-        let region = extract_zeta2_current_region(prompt, ZetaFormat::V0112MiddleAtEnd).unwrap();
-        assert_eq!(region, "println!(\"hello\");\n");
-    }
-
-    #[test]
-    fn test_extract_zeta2_current_region_with_cursor_marker() {
-        let prompt = indoc::indoc! {"
-            <|file_sep|>src/main.rs
-            <|fim_prefix|>
-            fn main() {
-            <|fim_middle|>current
-            print<|user_cursor|>ln!(\"hello\");
-            <|fim_suffix|>
-            }
-            <|fim_middle|>updated
-        "};
-
-        let region = extract_zeta2_current_region(prompt, ZetaFormat::V0113Ordered).unwrap();
-        assert_eq!(region, "println!(\"hello\");\n");
-    }
-
-    #[test]
-    fn test_extract_zeta2_current_region_v0120_git_merge_markers() {
-        let prompt = indoc::indoc! {"
-            <|file_sep|>src/main.rs
-            <|fim_prefix|>
-            fn main() {
-            <|fim_suffix|>
-            }
-            <|fim_middle|><<<<<<< CURRENT
-            println!(\"hello\");
-            =======
-        "};
-
-        let region =
-            extract_zeta2_current_region(prompt, ZetaFormat::V0120GitMergeMarkers).unwrap();
-        assert_eq!(region, "println!(\"hello\");\n");
-    }
-
-    #[test]
-    fn test_extract_zeta2_current_region_v0120_with_cursor_marker() {
-        let prompt = indoc::indoc! {"
-            <|file_sep|>src/main.rs
-            <|fim_prefix|>
-            fn main() {
-            <|fim_suffix|>
-            }
-            <|fim_middle|><<<<<<< CURRENT
-            print<|user_cursor|>ln!(\"hello\");
-            =======
-        "};
-
-        let region =
-            extract_zeta2_current_region(prompt, ZetaFormat::V0120GitMergeMarkers).unwrap();
-        assert_eq!(region, "println!(\"hello\");\n");
-    }
-}

crates/edit_prediction_cli/src/predict.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     FormatPromptArgs, PredictArgs, PredictionProvider, TeacherBackend,
     anthropic_client::AnthropicClient,
     example::{Example, ExamplePrediction, ExamplePrompt},
-    format_prompt::{TeacherPrompt, run_format_prompt},
+    format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt, run_format_prompt},
     headless::EpAppState,
     load_project::run_load_project,
     openai_client::OpenAiClient,
@@ -57,8 +57,10 @@ pub async fn run_prediction(
         );
     };
 
-    if let PredictionProvider::Teacher(backend) | PredictionProvider::TeacherNonBatching(backend) =
-        provider
+    if let PredictionProvider::Teacher(backend)
+    | PredictionProvider::TeacherMultiRegion(backend)
+    | PredictionProvider::TeacherNonBatching(backend)
+    | PredictionProvider::TeacherMultiRegionNonBatching(backend) = provider
     {
         run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?;
         run_format_prompt(
@@ -71,7 +73,10 @@ pub async fn run_prediction(
         .await?;
 
         let step_progress = example_progress.start(Step::Predict);
-        let batched = matches!(provider, PredictionProvider::Teacher(..));
+        let batched = matches!(
+            provider,
+            PredictionProvider::Teacher(..) | PredictionProvider::TeacherMultiRegion(..)
+        );
         return predict_teacher(
             example,
             backend,
@@ -135,7 +140,9 @@ pub async fn run_prediction(
             PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep,
             PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury,
             PredictionProvider::Teacher(..)
+            | PredictionProvider::TeacherMultiRegion(..)
             | PredictionProvider::TeacherNonBatching(..)
+            | PredictionProvider::TeacherMultiRegionNonBatching(..)
             | PredictionProvider::Repair
             | PredictionProvider::Baseten(_) => {
                 unreachable!()
@@ -403,7 +410,29 @@ async fn predict_anthropic(
             .collect::<Vec<String>>()
             .join("\n");
 
-        let (actual_patch, actual_cursor) = TeacherPrompt::parse(example, &actual_output)?;
+        let parser_provider = if batched {
+            example
+                .prompt
+                .as_ref()
+                .map(|prompt| prompt.provider)
+                .unwrap_or(PredictionProvider::Teacher(backend))
+        } else {
+            match example.prompt.as_ref().map(|prompt| prompt.provider) {
+                Some(PredictionProvider::TeacherMultiRegion(_))
+                | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
+                    PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                }
+                _ => PredictionProvider::TeacherNonBatching(backend),
+            }
+        };
+
+        let (actual_patch, actual_cursor) = match parser_provider {
+            PredictionProvider::TeacherMultiRegion(_)
+            | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
+                TeacherMultiRegionPrompt::parse(example, &actual_output)?
+            }
+            _ => TeacherPrompt::parse(example, &actual_output)?,
+        };
 
         let prediction = ExamplePrediction {
             actual_patch: Some(actual_patch),
@@ -411,9 +440,20 @@ async fn predict_anthropic(
             actual_cursor,
             error: None,
             provider: if batched {
-                PredictionProvider::Teacher(backend)
+                match example.prompt.as_ref().map(|prompt| prompt.provider) {
+                    Some(PredictionProvider::TeacherMultiRegion(_)) => {
+                        PredictionProvider::TeacherMultiRegion(backend)
+                    }
+                    _ => PredictionProvider::Teacher(backend),
+                }
             } else {
-                PredictionProvider::TeacherNonBatching(backend)
+                match example.prompt.as_ref().map(|prompt| prompt.provider) {
+                    Some(PredictionProvider::TeacherMultiRegion(_))
+                    | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
+                        PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                    }
+                    _ => PredictionProvider::TeacherNonBatching(backend),
+                }
             },
         };
 
@@ -487,7 +527,29 @@ async fn predict_openai(
             .collect::<Vec<String>>()
             .join("\n");
 
-        let (actual_patch, actual_cursor) = TeacherPrompt::parse(example, &actual_output)?;
+        let parser_provider = if batched {
+            example
+                .prompt
+                .as_ref()
+                .map(|prompt| prompt.provider)
+                .unwrap_or(PredictionProvider::Teacher(backend))
+        } else {
+            match example.prompt.as_ref().map(|prompt| prompt.provider) {
+                Some(PredictionProvider::TeacherMultiRegion(_))
+                | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
+                    PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                }
+                _ => PredictionProvider::TeacherNonBatching(backend),
+            }
+        };
+
+        let (actual_patch, actual_cursor) = match parser_provider {
+            PredictionProvider::TeacherMultiRegion(_)
+            | PredictionProvider::TeacherMultiRegionNonBatching(_) => {
+                TeacherMultiRegionPrompt::parse(example, &actual_output)?
+            }
+            _ => TeacherPrompt::parse(example, &actual_output)?,
+        };
 
         let prediction = ExamplePrediction {
             actual_patch: Some(actual_patch),
@@ -495,9 +557,20 @@ async fn predict_openai(
             actual_cursor,
             error: None,
             provider: if batched {
-                PredictionProvider::Teacher(backend)
+                match example.prompt.as_ref().map(|prompt| prompt.provider) {
+                    Some(PredictionProvider::TeacherMultiRegion(_)) => {
+                        PredictionProvider::TeacherMultiRegion(backend)
+                    }
+                    _ => PredictionProvider::Teacher(backend),
+                }
             } else {
-                PredictionProvider::TeacherNonBatching(backend)
+                match example.prompt.as_ref().map(|prompt| prompt.provider) {
+                    Some(PredictionProvider::TeacherMultiRegion(_))
+                    | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => {
+                        PredictionProvider::TeacherMultiRegionNonBatching(backend)
+                    }
+                    _ => PredictionProvider::TeacherNonBatching(backend),
+                }
             },
         };
 
@@ -591,7 +664,8 @@ pub async fn predict_baseten(
 
 pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Result<()> {
     match provider {
-        Some(PredictionProvider::Teacher(backend)) => match backend {
+        Some(PredictionProvider::Teacher(backend))
+        | Some(PredictionProvider::TeacherMultiRegion(backend)) => match backend {
             TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => {
                 let llm_client = ANTHROPIC_CLIENT.get_or_init(|| {
                     AnthropicClient::batch(&crate::paths::LLM_CACHE_DB)

crates/edit_prediction_cli/src/prompts/teacher_multi_region.md 🔗

@@ -0,0 +1,366 @@
+# Instructions
+
+You are an edit prediction assistant in a code editor. Your task is to predict the next edit to a given region of code surrounding the user's cursor.
+
+1. Analyze the edit history to understand what the programmer is trying to achieve
+2. Identify any incomplete refactoring or changes that need to be finished
+3. Make the remaining edits that a human programmer would logically make next (by rewriting a region of code near their cursor)
+
+## Focus on
+
+- Completing any partially-applied changes made
+- Ensuring consistency with the programming style and patterns already established
+- Making edits that maintain or improve code quality
+
+## Rules
+
+- **NEVER undo or revert the user's recent edits.** Examine the diff in the edit history carefully:
+  - If a line was removed (starts with `-`), do NOT restore that content—even if the code now appears incomplete or broken without it
+  - If a line was added (starts with `+`), do NOT delete or significantly modify it
+  - If code appears broken or incomplete after the user's edit, output `NO_EDITS` rather than "fixing" it by reverting
+  - Only add NEW content that extends the user's work forward; never restore what they removed
+  - **Key test**: if your prediction would make the code more similar to what it was BEFORE the user's edit, output `NO_EDITS` instead
+  - **Never assume a deletion was accidental.** Even if removing content breaks the code, breaks a pattern, or leaves text looking "incomplete", respect it. The user may be mid-rewrite. Do NOT "complete" partial text by restoring what was deleted.
+- Auto-generated code can be modified: Hunks marked with `// User accepted prediction:` contain code from a previous prediction the user accepted. Unlike user-typed content, these hunks CAN be edited, corrected, or replaced if it improves the code. The "never undo/revert" rule protects the user's *current typing intent*—auto-generated code doesn't carry this protection
+- Do not just mechanically apply patterns - reason about what changes make sense given the context and the programmer's apparent goals.
+- Do not just fix syntax errors - look for the broader refactoring pattern and apply it systematically throughout the code.
+- Keep existing formatting unless it's absolutely necessary
+- When edit history and surrounding code suggest different edits, prioritize the most recent edits in the history as they best reflect current intent.
+- Treat partial text at or near the cursor as the beginning of something the user is actively typing. Complete the code the user appears to be creating based on context.
+- When completing partial code, prefer predictions that save meaningful keystrokes, even if this requires making educated guesses about the user's intent.
+- For code, it's better to make a substantive prediction that might be rejected than to make a minimal prediction that saves only a few keystrokes.
+- When the user is editing prose or documentation (e.g. Markdown, comments, plain text), predict conservatively. Complete the current fragment or sentence, but do not generate additional lines of free-form content since prose is less constrained than code and more prone to incorrect continuations.
+
+# Input Format
+
+You will be provided with:
+1. The user's *edit history*, in chronological order. Use this to infer the user's trajectory and predict the next most logical edit.
+  - Hunks preceded by `// User accepted prediction:` indicate code that was auto-generated by a previous prediction and accepted by the user. These are treated differently than user-typed edits (see Rules).
+2. A set of *related excerpts* from the user's codebase. Some of these may be needed for correctly predicting the next edit.
+  - `…` may appear within a related file to indicate that some code has been skipped.
+3. An excerpt from the user's *current file*.
+    - The excerpt contains numbered *marker* tags (`<|marker_1|>`, `<|marker_2|>`, etc.) placed at block boundaries throughout the code. These markers divide the excerpt into spans that you can target for editing.
+    - Code that appears before the first marker or after the last marker is read-only context and cannot be edited.
+    - The `<|user_cursor|>` tag marks the user's current cursor position, as it stands after the last edit in the history.
+
+# Output Format
+
+- Briefly explain the user's current intent based on the edit history and their current cursor location.
+- Output a markdown codeblock containing your predicted edit as a **marker-bounded span**:
+  - The codeblock must **start** with a marker tag (e.g. `<|marker_2|>`) and **end** with a marker tag (e.g. `<|marker_4|>`).
+  - The content between these two markers is the full replacement for that span in the original file.
+  - Choose the **narrowest** pair of markers that fully contains your predicted edits, to minimize unnecessary output.
+  - Reproduce any unchanged lines within the chosen span faithfully — do not omit or alter them.
+  - Do not include any intermediate marker tags in your output — only the start and end markers.
+- If no edit is needed (the code is already complete and correct, or there is no clear next edit to make), output a codeblock containing only `NO_EDITS`:
+  `````
+  NO_EDITS
+  `````
+- If there is a specific place in the predicted output where the user is likely to edit next, indicate it using the `<|user_cursor|>` tag.
+
+## Example 1
+
+There is code missing at the cursor location. The related excerpts includes the definition of a relevant type. You should fill in the missing code.
+
+### Related Excerpts
+
+`````
+struct Product {
+    name: String,
+    price: u32,
+}
+`````
+
+### User Edit History
+
+`````
+--- a/src/calculate.rs
++++ b/src/calculate.rs
+@@ -100,6 +100,7 @@
+ fn calculate_total(products: &[Product]) -> u32 {
+     let mut total = 0;
+     for product in products {
++        total += ;
+     }
+     total
+ }
+`````
+
+### Current File
+
+`````src/calculate.rs
+fn calculate_total(products: &[Product]) -> u32 {
+<|marker_1|>
+    let mut total = 0;
+    for product in products {
+        total += <|user_cursor|>;
+    }
+    total
+<|marker_2|>
+}
+`````
+
+### Output
+
+The user is computing a sum based on a list of products. The only numeric field on `Product` is `price`, so they must intend to sum the prices.
+
+`````
+<|marker_1|>
+    let mut total = 0;
+    for product in products {
+        total += product.price;
+    }
+    total
+<|marker_2|>
+`````
+
+## Example 2
+
+The user appears to be in the process of typing an eprintln call. Rather than fixing the spelling issue by deleting the newly-inserted content, you must continue the user's trajectory. It's not clear what data they intend to print. You should fill in as much code as is obviously intended, and position the cursor so that the user can fill in the rest.
+
+### User Edit History
+
+`````
+--- a/src/modal.rs
++++ b/src/modal.rs
+@@ -100,4 +100,4 @@
+ fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
+     modal_state.close();
+-     modal_state.dismiss();
++     eprmodal_state.dismiss();
+ }
+`````
+
+### Current File
+
+`````src/modal.rs
+<|marker_1|>
+// handle the close button click
+fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
+<|marker_2|>
+    modal_state.close();
+    epr<|user_cursor|>modal_state.dismiss();
+}
+<|marker_3|>
+`````
+
+### Output
+
+The user is clearly starting to type `eprintln!()`, however, what they intend to print is not obvious. I should fill in the print call and string literal, with the cursor positioned inside the string literal so the user can print whatever they want.
+
+`````
+<|marker_2|>
+    modal_state.close();
+    eprintln!("<|user_cursor|>");
+    modal_state.dismiss();
+}
+<|marker_3|>
+`````
+
+## Example 3
+
+Here, the user is adding a function. There's no way to tell for sure what the function's name will be. In this situation, you should make a reasonable guess at the function's name and signature, and place the user's cursor in the function body. This way, if you guess correctly, it will save the user a meaningful number of keystrokes, and the file will be left in a coherent state.
+
+### User Edit History
+
+`````
+--- a/src/modal.rs
++++ b/src/modal.rs
+@@ -100,4 +100,4 @@
+ fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
+     modal_state.close();
+     modal_state.dismiss();
+ }
++
++fn
+
+ fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) {
+`````
+
+### Current File
+
+`````src/modal.rs
+// handle the close button click
+fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) {
+    modal_state.close();
+<|marker_1|>
+    modal_state.dismiss();
+}
+
+fn<|user_cursor|>
+
+<|marker_2|>
+fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) {
+    modal_state.begin_edit();
+<|marker_3|>
+`````
+
+### Output
+
+The user is adding a new function. The existing functions I see are `handle_close_button_click` and `handle_keystroke`, which have similar signatures. One possible function they might be adding is `handle_submit`.
+
+`````
+<|marker_1|>
+    modal_state.dismiss();
+}
+
+fn handle_submit(modal_state: &mut ModalState, evt: &Event) {
+    <|user_cursor|>
+}
+
+<|marker_2|>
+`````
+
+## Example 4
+
+The code is already complete and there is no clear next edit to make. You should output NO_EDITS.
+
+### User Edit History
+
+`````
+--- a/src/utils.rs
++++ b/src/utils.rs
+@@ -10,7 +10,7 @@
+ fn add(a: i32, b: i32) -> i32 {
+-    a - b
++    a + b
+ }
+`````
+
+### Current File
+
+`````src/utils.rs
+<|marker_1|>
+fn add(a: i32, b: i32) -> i32 {
+    a + b<|user_cursor|>
+}
+<|marker_2|>
+`````
+
+### Output
+
+The user just fixed a bug in the `add` function, changing subtraction to addition. The code is now correct and complete. There is no clear next edit to make.
+
+`````
+NO_EDITS
+`````
+
+## Example 5
+
+The user just deleted code, leaving behind what looks incomplete. You must NOT "complete" it by restoring deleted content—that would undo their edit. Output NO_EDITS. **This is the correct response even though the code appears broken.**
+
+### User Edit History
+
+`````
+--- a/config.nix
++++ b/config.nix
+@@ -10,7 +10,7 @@
+     # /etc/modular/crashdb needs to be mutable
+-    ln -s /tmp/crashdb $out/etc/modular/crashdb
++    ln -s /tmp/cr $out/etc/modular/crashdb
+   '';
+`````
+
+### Current File
+
+`````config.nix
+<|marker_1|>
+    # /etc/modular/crashdb needs to be mutable
+    ln -s /tmp/cr<|user_cursor|> $out/etc/modular/crashdb
+  '';
+<|marker_2|>
+`````
+
+### Output
+
+The user deleted `ashdb` from `/tmp/crashdb`, leaving `/tmp/cr`. Although this looks like incomplete text that I could "complete", doing so would restore deleted content. The user intentionally removed that text—I must not undo their deletion.
+
+`````
+NO_EDITS
+`````
+
+## Example 6
+
+The user accepted a prediction for a function, then started renaming it. The original arguments were auto-generated (marked with `// User accepted prediction:`), so they CAN be updated to match the new function name. This is NOT reverting user input—it's improving auto-generated scaffolding.
+
+### User Edit History
+
+`````
+--- a/math_utils.py
++++ b/math_utils.py
+@@ -3,3 +3,5 @@
+ def calculate_rectangle_area(width, height):
+     return width * height
+
+
++de
+
+// User accepted prediction:
+--- a/math_utils.py
++++ b/math_utils.py
+@@ -3,5 +3,7 @@
+ def calculate_rectangle_area(width, height):
+     return width * height
+
+-de
++def calculate_rectangle_perimeter(width, height):
++
+
+--- a/math_utils.py
++++ b/math_utils.py
+@@ -5,5 +5,5 @@
+     return width * height
+
+-def calculate_rectangle_perimeter(width, height):
++def calculate_sq_perimeter(width, height):
+
+`````
+
+### Current File
+
+`````math_utils.py
+<|marker_1|>
+def calculate_rectangle_area(width, height):
+    return width * height
+
+<|marker_2|>
+def calculate_sq<|user_cursor|>_perimeter(width, height):
+
+<|marker_3|>
+`````
+
+### Output
+
+The user accepted a prediction for `calculate_rectangle_perimeter(width, height)`, then started renaming `rectangle` to `square`. Since squares have equal sides, the arguments should change from `(width, height)` to `(side)`. The arguments were auto-generated (from an accepted prediction), so modifying them is appropriate.
+
+`````
+<|marker_2|>
+def calculate_square_perimeter(side):
+    <|user_cursor|>
+<|marker_3|>
+`````
+
+
+
+# Your task:
+
+# 1. User Edit History
+
+`````
+{{edit_history}}
+`````
+
+# 2. Related excerpts
+
+{{context}}
+
+# 3. Current File
+
+{{cursor_excerpt}}
+
+
+
+
+-----
+
+Based on the edit history and context above, predict the user's next edit within the marker-bounded spans.

crates/edit_prediction_cli/src/pull_examples.rs 🔗

@@ -565,6 +565,101 @@ pub async fn fetch_requested_examples_after(
     Ok(all_examples)
 }
 
+pub async fn fetch_captured_examples_after(
+    http_client: Arc<dyn HttpClient>,
+    after_timestamps: &[String],
+    max_rows_per_timestamp: usize,
+    offset: usize,
+    background_executor: BackgroundExecutor,
+    min_capture_version: Option<MinCaptureVersion>,
+) -> Result<Vec<Example>> {
+    if after_timestamps.is_empty() {
+        return Ok(Vec::new());
+    }
+
+    let progress = Progress::global();
+
+    let mut all_examples = Vec::new();
+
+    for after_date in after_timestamps.iter() {
+        let step_progress_name = format!("captured>{after_date}");
+        let step_progress = progress.start(Step::PullExamples, &step_progress_name);
+        step_progress.set_substatus("querying");
+
+        let min_minor_str = min_capture_version.map(|version| version.minor.to_string());
+        let min_patch_str = min_capture_version.map(|version| version.patch.to_string());
+        let min_minor_str_ref = min_minor_str.as_deref();
+        let min_patch_str_ref = min_patch_str.as_deref();
+
+        let statement = indoc! {r#"
+            SELECT
+                settled.event_properties:request_id::string AS request_id,
+                settled.device_id::string AS device_id,
+                settled.time::string AS time,
+                req.event_properties:input AS input,
+                settled.event_properties:settled_editable_region::string AS settled_editable_region,
+                settled.event_properties:example AS example,
+                req.event_properties:zed_version::string AS zed_version
+            FROM events settled
+            INNER JOIN events req
+                ON settled.event_properties:request_id::string = req.event_properties:request_id::string
+            WHERE settled.event_type = ?
+                AND req.event_type = ?
+                AND req.event_properties:version = 'V3'
+                AND req.event_properties:input:can_collect_data = true
+                AND settled.event_properties:example IS NOT NULL
+                AND TYPEOF(settled.event_properties:example) != 'NULL_VALUE'
+                AND settled.time > TRY_TO_TIMESTAMP_NTZ(?)
+                AND (? IS NULL OR (
+                    TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) > ?
+                    OR (
+                        TRY_CAST(SPLIT_PART(req.event_properties:zed_version::string, '.', 2) AS INTEGER) = ?
+                        AND TRY_CAST(SPLIT_PART(SPLIT_PART(req.event_properties:zed_version::string, '.', 3), '+', 1) AS INTEGER) >= ?
+                    )
+                ))
+            ORDER BY settled.time ASC
+            LIMIT ?
+            OFFSET ?
+        "#};
+
+        let bindings = json!({
+            "1": { "type": "TEXT", "value": EDIT_PREDICTION_SETTLED_EVENT },
+            "2": { "type": "TEXT", "value": PREDICTIVE_EDIT_REQUESTED_EVENT },
+            "3": { "type": "TEXT", "value": after_date },
+            "4": { "type": "FIXED", "value": min_minor_str_ref },
+            "5": { "type": "FIXED", "value": min_minor_str_ref },
+            "6": { "type": "FIXED", "value": min_minor_str_ref },
+            "7": { "type": "FIXED", "value": min_patch_str_ref },
+            "8": { "type": "FIXED", "value": max_rows_per_timestamp.to_string() },
+            "9": { "type": "FIXED", "value": offset.to_string() }
+        });
+
+        let examples = fetch_examples_with_query(
+            http_client.clone(),
+            &step_progress,
+            background_executor.clone(),
+            statement,
+            bindings,
+            DEFAULT_STATEMENT_TIMEOUT_SECONDS,
+            &[
+                "request_id",
+                "device_id",
+                "time",
+                "input",
+                "settled_editable_region",
+                "example",
+                "zed_version",
+            ],
+            captured_examples_from_response,
+        )
+        .await?;
+
+        all_examples.extend(examples);
+    }
+
+    Ok(all_examples)
+}
+
 pub async fn fetch_settled_examples_after(
     http_client: Arc<dyn HttpClient>,
     after_timestamps: &[String],
@@ -1018,7 +1113,7 @@ fn settled_examples_from_response<'a>(
                 }
             };
 
-            let parse_json_value = |_: &str, raw: Option<&JsonValue>| -> Option<JsonValue> {
+            let parse_json_value = |raw: Option<&JsonValue>| -> Option<JsonValue> {
                 let value = raw?;
                 match value {
                     JsonValue::String(s) => serde_json::from_str::<JsonValue>(s).ok(),
@@ -1030,7 +1125,7 @@ fn settled_examples_from_response<'a>(
             let device_id = get_string("device_id");
             let time = get_string("time");
             let input_raw = get_value("input");
-            let input_json = parse_json_value("input", input_raw.as_ref());
+            let input_json = parse_json_value(input_raw.as_ref());
             let input: Option<ZetaPromptInput> = input_json
                 .as_ref()
                 .and_then(|parsed| serde_json::from_value(parsed.clone()).ok());
@@ -1104,6 +1199,133 @@ fn settled_examples_from_response<'a>(
     Ok(Box::new(iter))
 }
 
+fn captured_examples_from_response<'a>(
+    response: &'a SnowflakeStatementResponse,
+    column_indices: &'a std::collections::HashMap<String, usize>,
+) -> Result<Box<dyn Iterator<Item = Example> + 'a>> {
+    if let Some(code) = &response.code {
+        if code != SNOWFLAKE_SUCCESS_CODE {
+            anyhow::bail!(
+                "snowflake sql api returned error code={code} message={}",
+                response.message.as_deref().unwrap_or("<no message>")
+            );
+        }
+    }
+
+    let iter = response
+        .data
+        .iter()
+        .enumerate()
+        .filter_map(move |(row_index, data_row)| {
+            let get_value = |name: &str| -> Option<JsonValue> {
+                let index = column_indices.get(name).copied()?;
+                let value = data_row.get(index)?;
+                if value.is_null() {
+                    None
+                } else {
+                    Some(value.clone())
+                }
+            };
+
+            let get_string = |name: &str| -> Option<String> {
+                match get_value(name)? {
+                    JsonValue::String(s) => Some(s),
+                    other => Some(other.to_string()),
+                }
+            };
+
+            let parse_json_value = |raw: Option<&JsonValue>| -> Option<JsonValue> {
+                let value = raw?;
+                match value {
+                    JsonValue::String(s) => serde_json::from_str::<JsonValue>(s).ok(),
+                    other => Some(other.clone()),
+                }
+            };
+
+            let request_id = get_string("request_id");
+            let device_id = get_string("device_id");
+            let time = get_string("time");
+            let input_raw = get_value("input");
+            let input_json = parse_json_value(input_raw.as_ref());
+            let input: Option<ZetaPromptInput> = input_json
+                .as_ref()
+                .and_then(|parsed| serde_json::from_value(parsed.clone()).ok());
+            let example_raw = get_value("example");
+            let example_json = parse_json_value(example_raw.as_ref());
+            let example_spec: Option<ExampleSpec> = example_json.as_ref().and_then(|parsed| {
+                serde_json::from_value(parsed.clone())
+                    .or_else(|_| {
+                        parsed
+                            .as_str()
+                            .and_then(|markdown| ExampleSpec::from_markdown(markdown).ok())
+                            .ok_or_else(|| {
+                                serde_json::Error::io(std::io::Error::other("not markdown"))
+                            })
+                    })
+                    .ok()
+            });
+            let has_example_spec = example_spec.is_some();
+            let settled_editable_region = get_string("settled_editable_region");
+            let zed_version = get_string("zed_version");
+
+            match (
+                request_id.clone(),
+                device_id.clone(),
+                time.clone(),
+                input.clone(),
+                example_spec,
+                settled_editable_region.clone(),
+            ) {
+                (
+                    Some(request_id),
+                    Some(device_id),
+                    Some(time),
+                    Some(input),
+                    Some(example_spec),
+                    Some(settled_editable_region),
+                ) => Some(build_captured_example(
+                    request_id,
+                    device_id,
+                    time,
+                    input,
+                    example_spec,
+                    settled_editable_region,
+                    zed_version,
+                )),
+                _ => {
+                    let mut missing_fields = Vec::new();
+
+                    if request_id.is_none() {
+                        missing_fields.push("request_id");
+                    }
+                    if device_id.is_none() {
+                        missing_fields.push("device_id");
+                    }
+                    if time.is_none() {
+                        missing_fields.push("time");
+                    }
+                    if input_raw.is_none() || input_json.is_none() || input.is_none() {
+                        missing_fields.push("input");
+                    }
+                    if example_raw.is_none() || !has_example_spec {
+                        missing_fields.push("example");
+                    }
+                    if settled_editable_region.is_none() {
+                        missing_fields.push("settled_editable_region");
+                    }
+
+                    log::warn!(
+                        "skipping captured row {row_index}: [{}]",
+                        missing_fields.join(", "),
+                    );
+                    None
+                }
+            }
+        });
+
+    Ok(Box::new(iter))
+}
+
 fn build_settled_example(
     request_id: String,
     device_id: String,
@@ -1160,6 +1382,43 @@ fn build_settled_example(
     example
 }
 
+fn build_captured_example(
+    request_id: String,
+    device_id: String,
+    time: String,
+    input: ZetaPromptInput,
+    mut example_spec: ExampleSpec,
+    settled_editable_region: String,
+    zed_version: Option<String>,
+) -> Example {
+    let expected_patch = build_output_patch(
+        &input.cursor_path,
+        input.cursor_excerpt.as_ref(),
+        &input.excerpt_ranges.editable_350,
+        settled_editable_region.as_str(),
+    );
+
+    example_spec.expected_patches = vec![expected_patch];
+    example_spec.telemetry = Some(TelemetrySource {
+        request_id,
+        device_id,
+        time,
+        rejection_reason: String::new(),
+        was_shown: false,
+    });
+
+    Example {
+        spec: example_spec,
+        zed_version,
+        prompt_inputs: Some(input),
+        prompt: None,
+        predictions: Vec::new(),
+        score: Vec::new(),
+        qa: Vec::new(),
+        state: None,
+    }
+}
+
 fn rejected_examples_from_response<'a>(
     response: &'a SnowflakeStatementResponse,
     column_indices: &'a std::collections::HashMap<String, usize>,

crates/edit_prediction_cli/src/repair.rs 🔗

@@ -227,16 +227,17 @@ pub fn needs_repair(example: &Example, confidence_threshold: u8) -> bool {
 /// Handles the `KEEP_PREVIOUS` sentinel by copying the teacher's prediction,
 /// and delegates normal output to `TeacherPrompt::parse`.
 pub fn parse(example: &Example, actual_output: &str) -> Result<(String, Option<ActualCursor>)> {
-    if let Some(last_codeblock) = extract_last_codeblock(actual_output) {
-        if last_codeblock.trim() == KEEP_PREVIOUS {
-            let original = example
-                .predictions
-                .first()
-                .context("no original prediction to keep")?;
-            let patch = original.actual_patch.clone().unwrap_or_default();
-            let cursor = original.actual_cursor.clone();
-            return Ok((patch, cursor));
-        }
+    let last_codeblock =
+        extract_last_codeblock(actual_output).unwrap_or_else(|| actual_output.to_string());
+
+    if last_codeblock.contains(KEEP_PREVIOUS) {
+        let original = example
+            .predictions
+            .first()
+            .context("no original prediction to keep")?;
+        let patch = original.actual_patch.clone().unwrap_or_default();
+        let cursor = original.actual_cursor.clone();
+        return Ok((patch, cursor));
     }
 
     TeacherPrompt::parse(example, actual_output)

crates/edit_prediction_cli/src/retrieve_context.rs 🔗

@@ -20,18 +20,13 @@ pub async fn run_context_retrieval(
     example_progress: &ExampleProgress,
     mut cx: AsyncApp,
 ) -> anyhow::Result<()> {
-    if example.prompt_inputs.is_some() {
-        if example.spec.repository_url.is_empty() {
-            return Ok(());
-        }
-
-        if example
-            .prompt_inputs
-            .as_ref()
-            .is_some_and(|inputs| !inputs.related_files.is_empty())
-        {
-            return Ok(());
-        }
+    if example
+        .prompt_inputs
+        .as_ref()
+        .is_some_and(|inputs| inputs.related_files.is_some())
+        || example.spec.repository_url.is_empty()
+    {
+        return Ok(());
     }
 
     run_load_project(example, app_state.clone(), example_progress, cx.clone()).await?;
@@ -72,7 +67,7 @@ pub async fn run_context_retrieval(
     step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal);
 
     if let Some(prompt_inputs) = example.prompt_inputs.as_mut() {
-        prompt_inputs.related_files = context_files;
+        prompt_inputs.related_files = Some(context_files);
     }
     Ok(())
 }

crates/edit_prediction_cli/src/reversal_tracking.rs 🔗

@@ -668,7 +668,8 @@ mod tests {
             cursor_offset_in_excerpt: 0,
             excerpt_start_row,
             events,
-            related_files: Vec::new(),
+            related_files: Some(Vec::new()),
+            active_buffer_diagnostics: Vec::new(),
             excerpt_ranges: ExcerptRanges {
                 editable_150: 0..content.len(),
                 editable_180: 0..content.len(),
@@ -678,6 +679,7 @@ mod tests {
                 editable_350_context_150: 0..content.len(),
                 ..Default::default()
             },
+            syntax_ranges: None,
             experiment: None,
             in_open_source_repo: false,
             can_collect_data: false,

crates/edit_prediction_context/Cargo.toml 🔗

@@ -42,4 +42,4 @@ serde_json.workspace = true
 settings = {workspace= true, features = ["test-support"]}
 text = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
-zlog.workspace = true
+

crates/edit_prediction_ui/Cargo.toml 🔗

@@ -50,18 +50,12 @@ zed_actions.workspace = true
 zeta_prompt.workspace = true
 
 [dev-dependencies]
-clock.workspace = true
 copilot = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 futures.workspace = true
 indoc.workspace = true
-language_model.workspace = true
-lsp = { workspace = true, features = ["test-support"] }
-pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
-release_channel.workspace = true
-semver.workspace = true
-serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
-zlog.workspace = true
+
+

crates/edit_prediction_ui/src/edit_prediction_button.rs 🔗

@@ -359,10 +359,16 @@ impl Render for EditPredictionButton {
                     }
                     EditPredictionProvider::Mercury => {
                         ep_icon = if enabled { icons.base } else { icons.disabled };
+                        let mercury_has_error =
+                            edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
+                                |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
+                            );
                         missing_token = edit_prediction::EditPredictionStore::try_global(cx)
                             .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
                         tooltip_meta = if missing_token {
                             "Missing API key for Mercury"
+                        } else if mercury_has_error {
+                            "Mercury free tier limit reached"
                         } else {
                             "Powered by Mercury"
                         };
@@ -414,7 +420,12 @@ impl Render for EditPredictionButton {
                 let show_editor_predictions = self.editor_show_predictions;
                 let user = self.user_store.read(cx).current_user();
 
-                let indicator_color = if missing_token {
+                let mercury_has_error = matches!(provider, EditPredictionProvider::Mercury)
+                    && edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
+                        |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
+                    );
+
+                let indicator_color = if missing_token || mercury_has_error {
                     Some(Color::Error)
                 } else if enabled && (!show_editor_predictions || over_limit) {
                     Some(if over_limit {
@@ -1096,96 +1107,116 @@ impl EditPredictionButton {
                         },
                     )
                     .separator();
-            } else if let Some(usage) = self
-                .edit_prediction_provider
-                .as_ref()
-                .and_then(|provider| provider.usage(cx))
-            {
-                menu = menu.header("Usage");
-                menu = menu
-                    .custom_entry(
-                        move |_window, cx| {
-                            let used_percentage = match usage.limit {
-                                UsageLimit::Limited(limit) => {
-                                    Some((usage.amount as f32 / limit as f32) * 100.)
-                                }
-                                UsageLimit::Unlimited => None,
-                            };
+            } else {
+                let mercury_payment_required = matches!(provider, EditPredictionProvider::Mercury)
+                    && edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
+                        |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
+                    );
+
+                if mercury_payment_required {
+                    menu = menu
+                        .header("Mercury")
+                        .item(ContextMenuEntry::new("Free tier limit reached").disabled(true))
+                        .item(
+                            ContextMenuEntry::new(
+                                "Upgrade to a paid plan to continue using the service",
+                            )
+                            .disabled(true),
+                        )
+                        .separator();
+                }
+
+                if let Some(usage) = self
+                    .edit_prediction_provider
+                    .as_ref()
+                    .and_then(|provider| provider.usage(cx))
+                {
+                    menu = menu.header("Usage");
+                    menu = menu
+                        .custom_entry(
+                            move |_window, cx| {
+                                let used_percentage = match usage.limit {
+                                    UsageLimit::Limited(limit) => {
+                                        Some((usage.amount as f32 / limit as f32) * 100.)
+                                    }
+                                    UsageLimit::Unlimited => None,
+                                };
 
-                            h_flex()
-                                .flex_1()
-                                .gap_1p5()
-                                .children(
-                                    used_percentage.map(|percent| {
+                                h_flex()
+                                    .flex_1()
+                                    .gap_1p5()
+                                    .children(used_percentage.map(|percent| {
                                         ProgressBar::new("usage", percent, 100., cx)
-                                    }),
-                                )
-                                .child(
-                                    Label::new(match usage.limit {
-                                        UsageLimit::Limited(limit) => {
-                                            format!("{} / {limit}", usage.amount)
-                                        }
-                                        UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
-                                    })
+                                    }))
+                                    .child(
+                                        Label::new(match usage.limit {
+                                            UsageLimit::Limited(limit) => {
+                                                format!("{} / {limit}", usage.amount)
+                                            }
+                                            UsageLimit::Unlimited => {
+                                                format!("{} / ∞", usage.amount)
+                                            }
+                                        })
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted),
+                                    )
+                                    .into_any_element()
+                            },
+                            move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
+                        )
+                        .when(usage.over_limit(), |menu| -> ContextMenu {
+                            menu.entry("Subscribe to increase your limit", None, |_window, cx| {
+                                telemetry::event!(
+                                    "Edit Prediction Menu Action",
+                                    action = "upsell_clicked",
+                                    reason = "usage_limit",
+                                );
+                                cx.open_url(&zed_urls::account_url(cx))
+                            })
+                        })
+                        .separator();
+                } else if self.user_store.read(cx).account_too_young() {
+                    menu = menu
+                        .custom_entry(
+                            |_window, _cx| {
+                                Label::new("Your GitHub account is less than 30 days old.")
                                     .size(LabelSize::Small)
-                                    .color(Color::Muted),
-                                )
-                                .into_any_element()
-                        },
-                        move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
-                    )
-                    .when(usage.over_limit(), |menu| -> ContextMenu {
-                        menu.entry("Subscribe to increase your limit", None, |_window, cx| {
+                                    .color(Color::Warning)
+                                    .into_any_element()
+                            },
+                            |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
+                        )
+                        .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
                             telemetry::event!(
                                 "Edit Prediction Menu Action",
                                 action = "upsell_clicked",
-                                reason = "usage_limit",
+                                reason = "account_age",
                             );
                             cx.open_url(&zed_urls::account_url(cx))
                         })
-                    })
-                    .separator();
-            } else if self.user_store.read(cx).account_too_young() {
-                menu = menu
-                    .custom_entry(
-                        |_window, _cx| {
-                            Label::new("Your GitHub account is less than 30 days old.")
-                                .size(LabelSize::Small)
-                                .color(Color::Warning)
-                                .into_any_element()
-                        },
-                        |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
-                    )
-                    .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
-                        telemetry::event!(
-                            "Edit Prediction Menu Action",
-                            action = "upsell_clicked",
-                            reason = "account_age",
-                        );
-                        cx.open_url(&zed_urls::account_url(cx))
-                    })
-                    .separator();
-            } else if self.user_store.read(cx).has_overdue_invoices() {
-                menu = menu
-                    .custom_entry(
-                        |_window, _cx| {
-                            Label::new("You have an outstanding invoice")
-                                .size(LabelSize::Small)
-                                .color(Color::Warning)
-                                .into_any_element()
-                        },
-                        |_window, cx| {
-                            cx.open_url(&zed_urls::account_url(cx))
-                        },
-                    )
-                    .entry(
-                        "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
-                        None,
-                        |_window, cx| {
-                            cx.open_url(&zed_urls::account_url(cx))
-                        },
-                    )
-                    .separator();
+                        .separator();
+                } else if self.user_store.read(cx).has_overdue_invoices() {
+                    menu = menu
+                        .custom_entry(
+                            |_window, _cx| {
+                                Label::new("You have an outstanding invoice")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Warning)
+                                    .into_any_element()
+                            },
+                            |_window, cx| {
+                                cx.open_url(&zed_urls::account_url(cx))
+                            },
+                        )
+                        .entry(
+                            "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
+                            None,
+                            |_window, cx| {
+                                cx.open_url(&zed_urls::account_url(cx))
+                            },
+                        )
+                        .separator();
+                }
             }
 
             if !needs_sign_in {
@@ -1195,6 +1226,9 @@ impl EditPredictionButton {
 
             if cx.is_staff() {
                 if let Some(store) = EditPredictionStore::try_global(cx) {
+                    store.update(cx, |store, cx| {
+                        store.refresh_available_experiments(cx);
+                    });
                     let store = store.read(cx);
                     let experiments = store.available_experiments().to_vec();
                     let preferred = store.preferred_experiment().map(|s| s.to_owned());

crates/edit_prediction_ui/src/rate_prediction_modal.rs 🔗

@@ -402,7 +402,13 @@ impl RatePredictionsModal {
 
             write!(&mut formatted_inputs, "## Related files\n\n").unwrap();
 
-            for included_file in prediction.inputs.related_files.iter() {
+            for included_file in prediction
+                .inputs
+                .related_files
+                .as_deref()
+                .unwrap_or_default()
+                .iter()
+            {
                 write!(
                     &mut formatted_inputs,
                     "### {}\n\n",
@@ -759,9 +765,7 @@ impl RatePredictionsModal {
                                 .gap_1()
                                 .child(
                                     Button::new("bad", "Bad Prediction")
-                                        .icon(IconName::ThumbsDown)
-                                        .icon_size(IconSize::Small)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(Icon::new(IconName::ThumbsDown).size(IconSize::Small))
                                         .disabled(rated || feedback_empty)
                                         .when(feedback_empty, |this| {
                                             this.tooltip(Tooltip::text(
@@ -785,9 +789,7 @@ impl RatePredictionsModal {
                                 )
                                 .child(
                                     Button::new("good", "Good Prediction")
-                                        .icon(IconName::ThumbsUp)
-                                        .icon_size(IconSize::Small)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(Icon::new(IconName::ThumbsUp).size(IconSize::Small))
                                         .disabled(rated)
                                         .key_binding(KeyBinding::for_action_in(
                                             &ThumbsUpActivePrediction,

crates/editor/Cargo.toml 🔗

@@ -26,6 +26,7 @@ test-support = [
     "tree-sitter-rust",
     "tree-sitter-typescript",
     "tree-sitter-html",
+    "proptest",
     "unindent",
 ]
 
@@ -63,6 +64,8 @@ ordered-float.workspace = true
 parking_lot.workspace = true
 pretty_assertions.workspace = true
 project.workspace = true
+proptest = { workspace = true, optional = true }
+proptest-derive = { workspace = true, optional = true }
 rand.workspace = true
 regex.workspace = true
 rpc.workspace = true
@@ -110,11 +113,13 @@ lsp = { workspace = true, features = ["test-support"] }
 markdown = { workspace = true, features = ["test-support"] }
 multi_buffer = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
+proptest.workspace = true
+proptest-derive.workspace = true
 release_channel.workspace = true
 rand.workspace = true
 semver.workspace = true
 settings = { workspace = true, features = ["test-support"] }
-tempfile.workspace = true
+
 text = { workspace = true, features = ["test-support"] }
 theme = { workspace = true, features = ["test-support"] }
 tree-sitter-c.workspace = true
@@ -128,7 +133,7 @@ unicode-width.workspace = true
 unindent.workspace = true
 util = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
-http_client = { workspace = true, features = ["test-support"] }
+
 zlog.workspace = true
 
 

crates/editor/src/bracket_colorization.rs 🔗

@@ -1455,6 +1455,60 @@ mod foo «1{
         );
     }
 
+    #[gpui::test]
+    // reproduction of #47846
+    async fn test_bracket_colorization_with_folds(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |language_settings| {
+            language_settings.defaults.colorize_brackets = Some(true);
+        });
+        let mut cx = EditorLspTestContext::new(
+            Arc::into_inner(rust_lang()).unwrap(),
+            lsp::ServerCapabilities::default(),
+            cx,
+        )
+        .await;
+
+        // Generate a large function body. When folded, this collapses
+        // to a single display line, making small_function visible on screen.
+        let mut big_body = String::new();
+        for i in 0..700 {
+            big_body.push_str(&format!("    let var_{i:04} = ({i});\n"));
+        }
+        let source = format!(
+            "ˇfn big_function() {{\n{big_body}}}\n\nfn small_function() {{\n    let x = (1, (2, 3));\n}}\n"
+        );
+
+        cx.set_state(&source);
+        cx.executor().advance_clock(Duration::from_millis(100));
+        cx.executor().run_until_parked();
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_ranges(
+                vec![Point::new(0, 0)..Point::new(701, 1)],
+                false,
+                window,
+                cx,
+            );
+        });
+        cx.executor().advance_clock(Duration::from_millis(100));
+        cx.executor().run_until_parked();
+
+        assert_eq!(
+            indoc! {r#"
+⋯1»
+
+fn small_function«1()1» «1{
+    let x = «2(1, «3(2, 3)3»)2»;
+}1»
+
+1 hsla(207.80, 16.20%, 69.19%, 1.00)
+2 hsla(29.00, 54.00%, 65.88%, 1.00)
+3 hsla(286.00, 51.00%, 75.25%, 1.00)
+"#,},
+            bracket_colors_markup(&mut cx),
+        );
+    }
+
     fn separate_with_comment_lines(head: &str, tail: &str, comment_lines: usize) -> String {
         let mut result = head.to_string();
         result.push_str("\n");

crates/editor/src/display_map.rs 🔗

@@ -107,7 +107,7 @@ use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType};
 use serde::Deserialize;
 use smallvec::SmallVec;
 use sum_tree::{Bias, TreeMap};
-use text::{BufferId, LineIndent, Patch, ToOffset as _};
+use text::{BufferId, LineIndent, Patch};
 use ui::{SharedString, px};
 use unicode_segmentation::UnicodeSegmentation;
 use ztracing::instrument;
@@ -1977,57 +1977,11 @@ impl DisplaySnapshot {
     /// Returned ranges are 0-based relative to `buffer_range.start`.
     pub(super) fn combined_highlights(
         &self,
-        buffer_id: BufferId,
-        buffer_range: Range<usize>,
+        multibuffer_range: Range<MultiBufferOffset>,
         syntax_theme: &theme::SyntaxTheme,
     ) -> Vec<(Range<usize>, HighlightStyle)> {
         let multibuffer = self.buffer_snapshot();
 
-        let multibuffer_range = multibuffer
-            .excerpts()
-            .find_map(|(excerpt_id, buffer, range)| {
-                if buffer.remote_id() != buffer_id {
-                    return None;
-                }
-                let context_start = range.context.start.to_offset(buffer);
-                let context_end = range.context.end.to_offset(buffer);
-                if buffer_range.start < context_start || buffer_range.end > context_end {
-                    return None;
-                }
-                let start_anchor = buffer.anchor_before(buffer_range.start);
-                let end_anchor = buffer.anchor_after(buffer_range.end);
-                let mb_range =
-                    multibuffer.anchor_range_in_excerpt(excerpt_id, start_anchor..end_anchor)?;
-                Some(mb_range.start.to_offset(multibuffer)..mb_range.end.to_offset(multibuffer))
-            });
-
-        let Some(multibuffer_range) = multibuffer_range else {
-            // Range is outside all excerpts (e.g. symbol name not in a
-            // multi-buffer excerpt). Fall back to buffer-level syntax highlights.
-            let buffer_snapshot = multibuffer.excerpts().find_map(|(_, buffer, _)| {
-                (buffer.remote_id() == buffer_id).then(|| buffer.clone())
-            });
-            let Some(buffer_snapshot) = buffer_snapshot else {
-                return Vec::new();
-            };
-            let mut highlights = Vec::new();
-            let mut offset = 0usize;
-            for chunk in buffer_snapshot.chunks(buffer_range, true) {
-                let chunk_len = chunk.text.len();
-                if chunk_len == 0 {
-                    continue;
-                }
-                if let Some(style) = chunk
-                    .syntax_highlight_id
-                    .and_then(|id| id.style(syntax_theme))
-                {
-                    highlights.push((offset..offset + chunk_len, style));
-                }
-                offset += chunk_len;
-            }
-            return highlights;
-        };
-
         let chunks = custom_highlights::CustomHighlightsChunks::new(
             multibuffer_range,
             true,

crates/editor/src/display_map/block_map.rs 🔗

@@ -1091,23 +1091,29 @@ impl BlockMap {
                 };
 
                 let rows_before_block;
-                match block_placement {
-                    BlockPlacement::Above(position) => {
-                        rows_before_block = position - new_transforms.summary().input_rows;
+                let input_rows = new_transforms.summary().input_rows;
+                match &block_placement {
+                    &BlockPlacement::Above(position) => {
+                        let Some(delta) = position.checked_sub(input_rows) else {
+                            continue;
+                        };
+                        rows_before_block = delta;
                         just_processed_folded_buffer = false;
                     }
-                    BlockPlacement::Near(position) | BlockPlacement::Below(position) => {
+                    &BlockPlacement::Near(position) | &BlockPlacement::Below(position) => {
                         if just_processed_folded_buffer {
                             continue;
                         }
-                        if position + RowDelta(1) < new_transforms.summary().input_rows {
+                        let Some(delta) = (position + RowDelta(1)).checked_sub(input_rows) else {
                             continue;
-                        }
-                        rows_before_block =
-                            (position + RowDelta(1)) - new_transforms.summary().input_rows;
+                        };
+                        rows_before_block = delta;
                     }
-                    BlockPlacement::Replace(ref range) => {
-                        rows_before_block = *range.start() - new_transforms.summary().input_rows;
+                    BlockPlacement::Replace(range) => {
+                        let Some(delta) = range.start().checked_sub(input_rows) else {
+                            continue;
+                        };
+                        rows_before_block = delta;
                         summary.input_rows = WrapRow(1) + (*range.end() - *range.start());
                         just_processed_folded_buffer = matches!(block, Block::FoldedBuffer { .. });
                     }

crates/editor/src/display_map/dimensions.rs 🔗

@@ -41,6 +41,10 @@ macro_rules! impl_for_row_types {
             pub fn saturating_sub(self, other: $row_delta) -> $row {
                 $row(self.0.saturating_sub(other.0))
             }
+
+            pub fn checked_sub(self, other: $row) -> Option<$row_delta> {
+                self.0.checked_sub(other.0).map($row_delta)
+            }
         }
 
         impl ::std::ops::Add for $row {

crates/editor/src/document_colors.rs 🔗

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

crates/editor/src/document_symbols.rs 🔗

@@ -1,4 +1,4 @@
-use std::{cmp, ops::Range};
+use std::ops::Range;
 
 use collections::HashMap;
 use futures::FutureExt;
@@ -6,10 +6,15 @@ use futures::future::join_all;
 use gpui::{App, Context, HighlightStyle, Task};
 use itertools::Itertools as _;
 use language::language_settings::language_settings;
-use language::{Buffer, BufferSnapshot, OutlineItem};
-use multi_buffer::{Anchor, MultiBufferSnapshot};
-use text::{Bias, BufferId, OffsetRangeExt as _, ToOffset as _};
+use language::{Buffer, OutlineItem};
+use multi_buffer::{
+    Anchor, AnchorRangeExt as _, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot,
+    ToOffset as _,
+};
+use text::BufferId;
 use theme::{ActiveTheme as _, SyntaxTheme};
+use unicode_segmentation::UnicodeSegmentation as _;
+use util::maybe;
 
 use crate::display_map::DisplaySnapshot;
 use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT};
@@ -77,6 +82,9 @@ impl Editor {
         let excerpt = multi_buffer_snapshot.excerpt_containing(cursor..cursor)?;
         let excerpt_id = excerpt.id();
         let buffer_id = excerpt.buffer_id();
+        if Some(buffer_id) != cursor.text_anchor.buffer_id {
+            return None;
+        }
         let buffer = self.buffer.read(cx).buffer(buffer_id)?;
         let buffer_snapshot = buffer.read(cx).snapshot();
         let cursor_text_anchor = cursor.text_anchor;
@@ -139,7 +147,7 @@ impl Editor {
         for_buffer: Option<BufferId>,
         cx: &mut Context<Self>,
     ) {
-        if !self.mode().is_full() {
+        if !self.lsp_data_enabled() {
             return;
         }
         let Some(project) = self.project.clone() else {
@@ -212,16 +220,13 @@ impl Editor {
                         let display_snapshot =
                             editor.display_map.update(cx, |map, cx| map.snapshot(cx));
                         let mut highlighted_results = results;
-                        for (buffer_id, items) in &mut highlighted_results {
-                            if let Some(buffer) = editor.buffer.read(cx).buffer(*buffer_id) {
-                                let snapshot = buffer.read(cx).snapshot();
-                                apply_highlights(
-                                    items,
-                                    *buffer_id,
-                                    &snapshot,
-                                    &display_snapshot,
-                                    &syntax,
-                                );
+                        for items in highlighted_results.values_mut() {
+                            for item in items {
+                                if let Some(highlights) =
+                                    highlights_from_buffer(&display_snapshot, &item, &syntax)
+                                {
+                                    item.highlight_ranges = highlights;
+                                }
                             }
                         }
                         editor.lsp_document_symbols.extend(highlighted_results);
@@ -239,34 +244,6 @@ fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool {
         .lsp_enabled()
 }
 
-/// Applies combined syntax + semantic token highlights to LSP document symbol
-/// outline items that were built without highlights by the project layer.
-fn apply_highlights(
-    items: &mut [OutlineItem<text::Anchor>],
-    buffer_id: BufferId,
-    buffer_snapshot: &BufferSnapshot,
-    display_snapshot: &DisplaySnapshot,
-    syntax_theme: &SyntaxTheme,
-) {
-    for item in items {
-        let symbol_range = item.range.to_offset(buffer_snapshot);
-        let selection_start = item.source_range_for_text.start.to_offset(buffer_snapshot);
-
-        if let Some(highlights) = highlights_from_buffer(
-            &item.text,
-            0,
-            buffer_id,
-            buffer_snapshot,
-            display_snapshot,
-            symbol_range,
-            selection_start,
-            syntax_theme,
-        ) {
-            item.highlight_ranges = highlights;
-        }
-    }
-}
-
 /// Finds where the symbol name appears in the buffer and returns combined
 /// (tree-sitter + semantic token) highlights for those positions.
 ///
@@ -275,117 +252,78 @@ fn apply_highlights(
 /// to word-by-word matching for cases like `impl<T> Trait<T> for Type`
 /// where the LSP name doesn't appear verbatim in the buffer.
 fn highlights_from_buffer(
-    name: &str,
-    name_offset_in_text: usize,
-    buffer_id: BufferId,
-    buffer_snapshot: &BufferSnapshot,
     display_snapshot: &DisplaySnapshot,
-    symbol_range: Range<usize>,
-    selection_start_offset: usize,
+    item: &OutlineItem<text::Anchor>,
     syntax_theme: &SyntaxTheme,
 ) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
-    if name.is_empty() {
+    let outline_text = &item.text;
+    if outline_text.is_empty() {
         return None;
     }
 
-    let range_start_offset = symbol_range.start;
-    let range_end_offset = symbol_range.end;
-
-    // Try to find the name verbatim in the buffer near the selection range.
-    let search_start = buffer_snapshot.clip_offset(
-        selection_start_offset
-            .saturating_sub(name.len())
-            .max(range_start_offset),
-        Bias::Right,
-    );
-    let search_end = buffer_snapshot.clip_offset(
-        cmp::min(selection_start_offset + name.len() * 2, range_end_offset),
-        Bias::Left,
-    );
-
-    if search_start < search_end {
-        let buffer_text: String = buffer_snapshot
-            .text_for_range(search_start..search_end)
-            .collect();
-        if let Some(found_at) = buffer_text.find(name) {
-            let name_start_offset = search_start + found_at;
-            let name_end_offset = name_start_offset + name.len();
-            let result = highlights_for_buffer_range(
-                name_offset_in_text,
-                name_start_offset..name_end_offset,
-                buffer_id,
-                display_snapshot,
-                syntax_theme,
+    let multi_buffer_snapshot = display_snapshot.buffer();
+    let multi_buffer_source_range_anchors =
+        multi_buffer_snapshot.text_anchors_to_visible_anchors([
+            item.source_range_for_text.start,
+            item.source_range_for_text.end,
+        ]);
+    let Some(anchor_range) = maybe!({
+        Some(
+            (*multi_buffer_source_range_anchors.get(0)?)?
+                ..(*multi_buffer_source_range_anchors.get(1)?)?,
+        )
+    }) else {
+        return None;
+    };
+
+    let selection_point_range = anchor_range.to_point(multi_buffer_snapshot);
+    let mut search_start = selection_point_range.start;
+    search_start.column = 0;
+    let search_start_offset = search_start.to_offset(&multi_buffer_snapshot);
+    let mut search_end = selection_point_range.end;
+    search_end.column = multi_buffer_snapshot.line_len(MultiBufferRow(search_end.row));
+
+    let search_text = multi_buffer_snapshot
+        .text_for_range(search_start..search_end)
+        .collect::<String>();
+
+    let mut outline_text_highlights = Vec::new();
+    match search_text.find(outline_text) {
+        Some(start_index) => {
+            let multibuffer_start = search_start_offset + MultiBufferOffset(start_index);
+            let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_text.len());
+            outline_text_highlights.extend(
+                display_snapshot
+                    .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme),
             );
-            if result.is_some() {
-                return result;
-            }
         }
-    }
-
-    // Fallback: match word-by-word. Split the name on whitespace and find
-    // each word sequentially in the buffer's symbol range.
-    let range_start_offset = buffer_snapshot.clip_offset(range_start_offset, Bias::Right);
-    let range_end_offset = buffer_snapshot.clip_offset(range_end_offset, Bias::Left);
-
-    let mut highlights = Vec::new();
-    let mut got_any = false;
-    let buffer_text: String = buffer_snapshot
-        .text_for_range(range_start_offset..range_end_offset)
-        .collect();
-    let mut buf_search_from = 0usize;
-    let mut name_search_from = 0usize;
-    for word in name.split_whitespace() {
-        let name_word_start = name[name_search_from..]
-            .find(word)
-            .map(|pos| name_search_from + pos)
-            .unwrap_or(name_search_from);
-        if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) {
-            let buf_word_start = range_start_offset + buf_search_from + found_in_buf;
-            let buf_word_end = buf_word_start + word.len();
-            let text_cursor = name_offset_in_text + name_word_start;
-            if let Some(mut word_highlights) = highlights_for_buffer_range(
-                text_cursor,
-                buf_word_start..buf_word_end,
-                buffer_id,
-                display_snapshot,
-                syntax_theme,
-            ) {
-                got_any = true;
-                highlights.append(&mut word_highlights);
+        None => {
+            for (outline_text_word_start, outline_word) in outline_text.split_word_bound_indices() {
+                if let Some(start_index) = search_text.find(outline_word) {
+                    let multibuffer_start = search_start_offset + MultiBufferOffset(start_index);
+                    let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_word.len());
+                    outline_text_highlights.extend(
+                        display_snapshot
+                            .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme)
+                            .into_iter()
+                            .map(|(range_in_word, style)| {
+                                (
+                                    outline_text_word_start + range_in_word.start
+                                        ..outline_text_word_start + range_in_word.end,
+                                    style,
+                                )
+                            }),
+                    );
+                }
             }
-            buf_search_from = buf_search_from + found_in_buf + word.len();
         }
-        name_search_from = name_word_start + word.len();
     }
 
-    got_any.then_some(highlights)
-}
-
-/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte
-/// range via the editor's display snapshot, then shifts the returned ranges
-/// so they start at `text_cursor_start` (the position in the outline item text).
-fn highlights_for_buffer_range(
-    text_cursor_start: usize,
-    buffer_range: Range<usize>,
-    buffer_id: BufferId,
-    display_snapshot: &DisplaySnapshot,
-    syntax_theme: &SyntaxTheme,
-) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
-    let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme);
-    if raw.is_empty() {
-        return None;
+    if outline_text_highlights.is_empty() {
+        None
+    } else {
+        Some(outline_text_highlights)
     }
-    Some(
-        raw.into_iter()
-            .map(|(range, style)| {
-                (
-                    range.start + text_cursor_start..range.end + text_cursor_start,
-                    style,
-                )
-            })
-            .collect(),
-    )
 }
 
 #[cfg(test)]

crates/editor/src/editor.rs 🔗

@@ -35,13 +35,13 @@ mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;
+mod runnables;
 mod rust_analyzer_ext;
 pub mod scroll;
 mod selections_collection;
 pub mod semantic_tokens;
 mod split;
 pub mod split_editor_view;
-pub mod tasks;
 
 #[cfg(test)]
 mod code_completion_tests;
@@ -133,8 +133,8 @@ use language::{
     BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
     DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
     IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt,
-    OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
-    TreeSitterOptions, WordsQuery,
+    OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
+    WordsQuery,
     language_settings::{
         self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
         all_language_settings, language_settings,
@@ -158,7 +158,7 @@ use project::{
     BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
     CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId,
     InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project,
-    ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
+    ProjectItem, ProjectPath, ProjectTransaction,
     debugger::{
         breakpoint_store::{
             Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
@@ -200,7 +200,7 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
+use task::TaskVariables;
 use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _};
 use theme::{
     AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme,
@@ -209,6 +209,7 @@ use theme::{
 use ui::{
     Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape,
     IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide,
+    utils::WithRemSize,
 };
 use ui_input::ErasedEditor;
 use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
@@ -216,7 +217,7 @@ use workspace::{
     CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, NavigationEntry, OpenInTerminal,
     OpenTerminal, Pane, RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection,
     TabBarSettings, Toast, ViewId, Workspace, WorkspaceId, WorkspaceSettings,
-    item::{BreadcrumbText, ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
+    item::{ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
     searchable::SearchEvent,
 };
@@ -230,6 +231,7 @@ use crate::{
         InlineValueCache,
         inlay_hints::{LspInlayHintData, inlay_hint_settings},
     },
+    runnables::{ResolvedTasks, RunnableData, RunnableTasks},
     scroll::{ScrollOffset, ScrollPixelOffset},
     selections_collection::resolve_selections_wrapping_blocks,
     semantic_tokens::SemanticTokenState,
@@ -856,37 +858,6 @@ impl BufferSerialization {
     }
 }
 
-#[derive(Clone, Debug)]
-struct RunnableTasks {
-    templates: Vec<(TaskSourceKind, TaskTemplate)>,
-    offset: multi_buffer::Anchor,
-    // We need the column at which the task context evaluation should take place (when we're spawning it via gutter).
-    column: u32,
-    // Values of all named captures, including those starting with '_'
-    extra_variables: HashMap<String, String>,
-    // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal.
-    context_range: Range<BufferOffset>,
-}
-
-impl RunnableTasks {
-    fn resolve<'a>(
-        &'a self,
-        cx: &'a task::TaskContext,
-    ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
-        self.templates.iter().filter_map(|(kind, template)| {
-            template
-                .resolve_task(&kind.to_id_base(), cx)
-                .map(|task| (kind.clone(), task))
-        })
-    }
-}
-
-#[derive(Clone)]
-pub struct ResolvedTasks {
-    templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
-    position: Anchor,
-}
-
 /// Addons allow storing per-editor state in other crates (e.g. Vim)
 pub trait Addon: 'static {
     fn extend_key_context(&self, _: &mut KeyContext, _: &App) {}
@@ -1233,6 +1204,7 @@ pub struct Editor {
     autoindent_mode: Option<AutoindentMode>,
     workspace: Option<(WeakEntity<Workspace>, Option<WorkspaceId>)>,
     input_enabled: bool,
+    expects_character_input: bool,
     use_modal_editing: bool,
     read_only: bool,
     leader_id: Option<CollaboratorId>,
@@ -1293,8 +1265,7 @@ pub struct Editor {
     last_bounds: Option<Bounds<Pixels>>,
     last_position_map: Option<Rc<PositionMap>>,
     expect_bounds_change: Option<Bounds<Pixels>>,
-    tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
-    tasks_update_task: Option<Task<()>>,
+    runnables: RunnableData,
     breakpoint_store: Option<Entity<BreakpointStore>>,
     gutter_breakpoint_indicator: (Option<PhantomBreakpointIndicator>, Option<Task<()>>),
     pub(crate) gutter_diff_review_indicator: (Option<PhantomDiffReviewIndicator>, Option<Task<()>>),
@@ -2171,16 +2142,9 @@ impl Editor {
                         editor.registered_buffers.clear();
                         editor.register_visible_buffers(cx);
                         editor.invalidate_semantic_tokens(None);
+                        editor.refresh_runnables(None, window, cx);
                         editor.update_lsp_data(None, window, cx);
                         editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
-                        if editor.tasks_update_task.is_none() {
-                            editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
-                        }
-                    }
-                    project::Event::LanguageServerAdded(..) => {
-                        if editor.tasks_update_task.is_none() {
-                            editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
-                        }
                     }
                     project::Event::SnippetEdit(id, snippet_edits) => {
                         // todo(lw): Non singletons
@@ -2208,6 +2172,7 @@ impl Editor {
                         let buffer_id = *buffer_id;
                         if editor.buffer().read(cx).buffer(buffer_id).is_some() {
                             editor.register_buffer(buffer_id, cx);
+                            editor.refresh_runnables(Some(buffer_id), window, cx);
                             editor.update_lsp_data(Some(buffer_id), window, cx);
                             editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
                             refresh_linked_ranges(editor, window, cx);
@@ -2286,7 +2251,7 @@ impl Editor {
                     &task_inventory,
                     window,
                     |editor, _, window, cx| {
-                        editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
+                        editor.refresh_runnables(None, window, cx);
                     },
                 ));
             };
@@ -2469,6 +2434,7 @@ impl Editor {
             collapse_matches: false,
             workspace: None,
             input_enabled: !is_minimap,
+            expects_character_input: !is_minimap,
             use_modal_editing: full_mode,
             read_only: is_minimap,
             use_autoclose: true,
@@ -2526,7 +2492,6 @@ impl Editor {
             }),
             blame: None,
             blame_subscription: None,
-            tasks: BTreeMap::default(),
 
             breakpoint_store,
             gutter_breakpoint_indicator: (None, None),
@@ -2562,7 +2527,7 @@ impl Editor {
                     ]
                 })
                 .unwrap_or_default(),
-            tasks_update_task: None,
+            runnables: RunnableData::new(),
             pull_diagnostics_task: Task::ready(()),
             colors: None,
             refresh_colors_task: Task::ready(()),
@@ -2629,7 +2594,6 @@ impl Editor {
                     cx.notify();
                 }));
         }
-        editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
         editor._subscriptions.extend(project_subscriptions);
 
         editor._subscriptions.push(cx.subscribe_in(
@@ -2657,15 +2621,7 @@ impl Editor {
                                 .await;
                             editor
                                 .update_in(cx, |editor, window, cx| {
-                                    editor.register_visible_buffers(cx);
-                                    editor.colorize_brackets(false, cx);
-                                    editor.refresh_inlay_hints(
-                                        InlayHintRefreshReason::NewLinesShown,
-                                        cx,
-                                    );
-                                    if !editor.buffer().read(cx).is_singleton() {
-                                        editor.update_lsp_data(None, window, cx);
-                                    }
+                                    editor.update_data_on_scroll(window, cx)
                                 })
                                 .ok();
                         });
@@ -3365,6 +3321,10 @@ impl Editor {
         self.input_enabled = input_enabled;
     }
 
+    pub fn set_expects_character_input(&mut self, expects_character_input: bool) {
+        self.expects_character_input = expects_character_input;
+    }
+
     pub fn set_edit_predictions_hidden_for_vim_mode(
         &mut self,
         hidden: bool,
@@ -5784,18 +5744,11 @@ impl Editor {
         let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let multi_buffer = self.buffer().read(cx);
         let multi_buffer_snapshot = multi_buffer.snapshot(cx);
-        let multi_buffer_visible_start = self
-            .scroll_manager
-            .native_anchor(&display_snapshot, cx)
-            .anchor
-            .to_point(&multi_buffer_snapshot);
-        let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
-            multi_buffer_visible_start
-                + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
-            Bias::Left,
-        );
         multi_buffer_snapshot
-            .range_to_buffer_ranges(multi_buffer_visible_start..=multi_buffer_visible_end)
+            .range_to_buffer_ranges(
+                self.multi_buffer_visible_range(&display_snapshot, cx)
+                    .to_inclusive(),
+            )
             .into_iter()
             .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty())
             .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| {
@@ -6730,8 +6683,8 @@ impl Editor {
         };
         let buffer_id = buffer.read(cx).remote_id();
         let tasks = self
-            .tasks
-            .get(&(buffer_id, buffer_row))
+            .runnables
+            .runnables((buffer_id, buffer_row))
             .map(|t| Arc::new(t.to_owned()));
 
         if !self.focus_handle.is_focused(window) {
@@ -7494,7 +7447,8 @@ impl Editor {
                     let mut read_ranges = Vec::new();
                     for highlight in highlights {
                         let buffer_id = cursor_buffer.read(cx).remote_id();
-                        for (excerpt_id, excerpt_range) in buffer.excerpts_for_buffer(buffer_id, cx)
+                        for (excerpt_id, _, excerpt_range) in
+                            buffer.excerpts_for_buffer(buffer_id, cx)
                         {
                             let start = highlight
                                 .range
@@ -7725,7 +7679,7 @@ impl Editor {
 
     #[ztracing::instrument(skip_all)]
     fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context<Editor>) {
-        if !self.mode.is_full() {
+        if !self.lsp_data_enabled() {
             return;
         }
         let cursor = self.selections.newest_anchor().head();
@@ -7781,24 +7735,13 @@ impl Editor {
             self.debounced_selection_highlight_complete = false;
         }
         if on_buffer_edit || query_changed {
-            let multi_buffer_visible_start = self
-                .scroll_manager
-                .native_anchor(&display_snapshot, cx)
-                .anchor
-                .to_point(&multi_buffer_snapshot);
-            let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
-                multi_buffer_visible_start
-                    + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
-                Bias::Left,
-            );
-            let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end;
             self.quick_selection_highlight_task = Some((
                 query_range.clone(),
                 self.update_selection_occurrence_highlights(
                     snapshot.buffer.clone(),
                     query_text.clone(),
                     query_range.clone(),
-                    multi_buffer_visible_range,
+                    self.multi_buffer_visible_range(&display_snapshot, cx),
                     false,
                     window,
                     cx,
@@ -7833,6 +7776,27 @@ impl Editor {
         }
     }
 
+    pub fn multi_buffer_visible_range(
+        &self,
+        display_snapshot: &DisplaySnapshot,
+        cx: &App,
+    ) -> Range<Point> {
+        let visible_start = self
+            .scroll_manager
+            .native_anchor(display_snapshot, cx)
+            .anchor
+            .to_point(display_snapshot.buffer_snapshot())
+            .to_display_point(display_snapshot);
+
+        let mut target_end = visible_start;
+        *target_end.row_mut() += self.visible_line_count().unwrap_or(0.).ceil() as u32;
+
+        visible_start.to_point(display_snapshot)
+            ..display_snapshot
+                .clip_point(target_end, Bias::Right)
+                .to_point(display_snapshot)
+    }
+
     pub fn refresh_edit_prediction(
         &mut self,
         debounce: bool,
@@ -8416,6 +8380,7 @@ impl Editor {
 
         self.update_hovered_link(
             position_map.point_for_position(mouse_position),
+            Some(mouse_position),
             &position_map.snapshot,
             modifiers,
             window,
@@ -8801,19 +8766,6 @@ impl Editor {
         Some(self.edit_prediction_provider.as_ref()?.provider.clone())
     }
 
-    fn clear_tasks(&mut self) {
-        self.tasks.clear()
-    }
-
-    fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) {
-        if self.tasks.insert(key, value).is_some() {
-            // This case should hopefully be rare, but just in case...
-            log::error!(
-                "multiple different run targets found on a single line, only the last target will be rendered"
-            )
-        }
-    }
-
     /// Get all display points of breakpoints that will be rendered within editor
     ///
     /// This function is used to handle overlaps between breakpoints and Code action/runner symbol.
@@ -9191,156 +9143,6 @@ impl Editor {
         })
     }
 
-    pub fn spawn_nearest_task(
-        &mut self,
-        action: &SpawnNearestTask,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some((workspace, _)) = self.workspace.clone() else {
-            return;
-        };
-        let Some(project) = self.project.clone() else {
-            return;
-        };
-
-        // Try to find a closest, enclosing node using tree-sitter that has a task
-        let Some((buffer, buffer_row, tasks)) = self
-            .find_enclosing_node_task(cx)
-            // Or find the task that's closest in row-distance.
-            .or_else(|| self.find_closest_task(cx))
-        else {
-            return;
-        };
-
-        let reveal_strategy = action.reveal;
-        let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
-        cx.spawn_in(window, async move |_, cx| {
-            let context = task_context.await?;
-            let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
-
-            let resolved = &mut resolved_task.resolved;
-            resolved.reveal = reveal_strategy;
-
-            workspace
-                .update_in(cx, |workspace, window, cx| {
-                    workspace.schedule_resolved_task(
-                        task_source_kind,
-                        resolved_task,
-                        false,
-                        window,
-                        cx,
-                    );
-                })
-                .ok()
-        })
-        .detach();
-    }
-
-    fn find_closest_task(
-        &mut self,
-        cx: &mut Context<Self>,
-    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
-        let cursor_row = self
-            .selections
-            .newest_adjusted(&self.display_snapshot(cx))
-            .head()
-            .row;
-
-        let ((buffer_id, row), tasks) = self
-            .tasks
-            .iter()
-            .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
-
-        let buffer = self.buffer.read(cx).buffer(*buffer_id)?;
-        let tasks = Arc::new(tasks.to_owned());
-        Some((buffer, *row, tasks))
-    }
-
-    fn find_enclosing_node_task(
-        &mut self,
-        cx: &mut Context<Self>,
-    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
-        let offset = self
-            .selections
-            .newest::<MultiBufferOffset>(&self.display_snapshot(cx))
-            .head();
-        let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
-        let offset = excerpt.map_offset_to_buffer(offset);
-        let buffer_id = excerpt.buffer().remote_id();
-
-        let layer = excerpt.buffer().syntax_layer_at(offset)?;
-        let mut cursor = layer.node().walk();
-
-        while cursor.goto_first_child_for_byte(offset.0).is_some() {
-            if cursor.node().end_byte() == offset.0 {
-                cursor.goto_next_sibling();
-            }
-        }
-
-        // Ascend to the smallest ancestor that contains the range and has a task.
-        loop {
-            let node = cursor.node();
-            let node_range = node.byte_range();
-            let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
-
-            // Check if this node contains our offset
-            if node_range.start <= offset.0 && node_range.end >= offset.0 {
-                // If it contains offset, check for task
-                if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) {
-                    let buffer = self.buffer.read(cx).buffer(buffer_id)?;
-                    return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
-                }
-            }
-
-            if !cursor.goto_parent() {
-                break;
-            }
-        }
-        None
-    }
-
-    fn render_run_indicator(
-        &self,
-        _style: &EditorStyle,
-        is_active: bool,
-        row: DisplayRow,
-        breakpoint: Option<(Anchor, Breakpoint, Option<BreakpointSessionState>)>,
-        cx: &mut Context<Self>,
-    ) -> IconButton {
-        let color = Color::Muted;
-        let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor);
-
-        IconButton::new(
-            ("run_indicator", row.0 as usize),
-            ui::IconName::PlayOutlined,
-        )
-        .shape(ui::IconButtonShape::Square)
-        .icon_size(IconSize::XSmall)
-        .icon_color(color)
-        .toggle_state(is_active)
-        .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| {
-            let quick_launch = match e {
-                ClickEvent::Keyboard(_) => true,
-                ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
-            };
-
-            window.focus(&editor.focus_handle(cx), cx);
-            editor.toggle_code_actions(
-                &ToggleCodeActions {
-                    deployed_from: Some(CodeActionSource::RunMenu(row)),
-                    quick_launch,
-                },
-                window,
-                cx,
-            );
-        }))
-        .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
-            editor.set_breakpoint_context_menu(row, position, event.position(), window, cx);
-        }))
-    }
-
     pub fn context_menu_visible(&self) -> bool {
         !self.edit_prediction_preview_is_active()
             && self
@@ -11677,6 +11479,43 @@ impl Editor {
         self.restore_hunks_in_ranges(selections, window, cx);
     }
 
+    /// Restores the diff hunks in the editor's selections and moves the cursor
+    /// to the next diff hunk. Wraps around to the beginning of the buffer if
+    /// not all diff hunks are expanded.
+    pub fn restore_and_next(
+        &mut self,
+        _: &::git::RestoreAndNext,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let selections = self
+            .selections
+            .all(&self.display_snapshot(cx))
+            .into_iter()
+            .map(|selection| selection.range())
+            .collect();
+
+        self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
+        self.restore_hunks_in_ranges(selections, window, cx);
+
+        let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded();
+        let wrap_around = !all_diff_hunks_expanded;
+        let snapshot = self.snapshot(window, cx);
+        let position = self
+            .selections
+            .newest::<Point>(&snapshot.display_snapshot)
+            .head();
+
+        self.go_to_hunk_before_or_after_position(
+            &snapshot,
+            position,
+            Direction::Next,
+            wrap_around,
+            window,
+            cx,
+        );
+    }
+
     pub fn restore_hunks_in_ranges(
         &mut self,
         ranges: Vec<Range<Point>>,
@@ -12599,9 +12438,7 @@ impl Editor {
         cx: &mut Context<Self>,
     ) {
         self.manipulate_text(window, cx, |text| {
-            text.split('\n')
-                .map(|line| line.to_case(Case::Title))
-                .join("\n")
+            Self::convert_text_case(text, Case::Title)
         })
     }
 
@@ -12611,7 +12448,9 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_text(window, cx, |text| text.to_case(Case::Snake))
+        self.manipulate_text(window, cx, |text| {
+            Self::convert_text_case(text, Case::Snake)
+        })
     }
 
     pub fn convert_to_kebab_case(
@@ -12620,7 +12459,9 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_text(window, cx, |text| text.to_case(Case::Kebab))
+        self.manipulate_text(window, cx, |text| {
+            Self::convert_text_case(text, Case::Kebab)
+        })
     }
 
     pub fn convert_to_upper_camel_case(
@@ -12630,9 +12471,7 @@ impl Editor {
         cx: &mut Context<Self>,
     ) {
         self.manipulate_text(window, cx, |text| {
-            text.split('\n')
-                .map(|line| line.to_case(Case::UpperCamel))
-                .join("\n")
+            Self::convert_text_case(text, Case::UpperCamel)
         })
     }
 
@@ -12642,7 +12481,9 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_text(window, cx, |text| text.to_case(Case::Camel))
+        self.manipulate_text(window, cx, |text| {
+            Self::convert_text_case(text, Case::Camel)
+        })
     }
 
     pub fn convert_to_opposite_case(
@@ -12670,7 +12511,9 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_text(window, cx, |text| text.to_case(Case::Sentence))
+        self.manipulate_text(window, cx, |text| {
+            Self::convert_text_case(text, Case::Sentence)
+        })
     }
 
     pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context<Self>) {
@@ -12701,6 +12544,18 @@ impl Editor {
         })
     }
 
+    fn convert_text_case(text: &str, case: Case) -> String {
+        text.lines()
+            .map(|line| {
+                let trimmed_start = line.trim_start();
+                let leading = &line[..line.len() - trimmed_start.len()];
+                let trimmed = trimmed_start.trim_end();
+                let trailing = &trimmed_start[trimmed.len()..];
+                format!("{}{}{}", leading, trimmed.to_case(case), trailing)
+            })
+            .join("\n")
+    }
+
     pub fn convert_to_rot47(
         &mut self,
         _: &ConvertToRot47,
@@ -14855,6 +14710,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let stop_at_indent = action.stop_at_indent && !self.mode.is_single_line();
         self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
         self.change_selections(Default::default(), window, cx, |s| {
             s.move_cursors_with(&mut |map, head, _| {
@@ -14863,7 +14719,7 @@ impl Editor {
                         map,
                         head,
                         action.stop_at_soft_wraps,
-                        action.stop_at_indent,
+                        stop_at_indent,
                     ),
                     SelectionGoal::None,
                 )
@@ -14877,6 +14733,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let stop_at_indent = action.stop_at_indent && !self.mode.is_single_line();
         self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
         self.change_selections(Default::default(), window, cx, |s| {
             s.move_heads_with(&mut |map, head, _| {
@@ -14885,7 +14742,7 @@ impl Editor {
                         map,
                         head,
                         action.stop_at_soft_wraps,
-                        action.stop_at_indent,
+                        stop_at_indent,
                     ),
                     SelectionGoal::None,
                 )
@@ -17108,236 +16965,6 @@ impl Editor {
         });
     }
 
-    fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
-        if !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables {
-            self.clear_tasks();
-            return Task::ready(());
-        }
-        let project = self.project().map(Entity::downgrade);
-        let task_sources = self.lsp_task_sources(cx);
-        let multi_buffer = self.buffer.downgrade();
-        cx.spawn_in(window, async move |editor, cx| {
-            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
-            let Some(project) = project.and_then(|p| p.upgrade()) else {
-                return;
-            };
-            let Ok(display_snapshot) = editor.update(cx, |this, cx| {
-                this.display_map.update(cx, |map, cx| map.snapshot(cx))
-            }) else {
-                return;
-            };
-
-            let hide_runnables = project.update(cx, |project, _| project.is_via_collab());
-            if hide_runnables {
-                return;
-            }
-            let new_rows =
-                cx.background_spawn({
-                    let snapshot = display_snapshot.clone();
-                    async move {
-                        Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max())
-                    }
-                })
-                    .await;
-            let Ok(lsp_tasks) =
-                cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx))
-            else {
-                return;
-            };
-            let lsp_tasks = lsp_tasks.await;
-
-            let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| {
-                lsp_tasks
-                    .into_iter()
-                    .flat_map(|(kind, tasks)| {
-                        tasks.into_iter().filter_map(move |(location, task)| {
-                            Some((kind.clone(), location?, task))
-                        })
-                    })
-                    .fold(HashMap::default(), |mut acc, (kind, location, task)| {
-                        let buffer = location.target.buffer;
-                        let buffer_snapshot = buffer.read(cx).snapshot();
-                        let offset = display_snapshot.buffer_snapshot().excerpts().find_map(
-                            |(excerpt_id, snapshot, _)| {
-                                if snapshot.remote_id() == buffer_snapshot.remote_id() {
-                                    display_snapshot
-                                        .buffer_snapshot()
-                                        .anchor_in_excerpt(excerpt_id, location.target.range.start)
-                                } else {
-                                    None
-                                }
-                            },
-                        );
-                        if let Some(offset) = offset {
-                            let task_buffer_range =
-                                location.target.range.to_point(&buffer_snapshot);
-                            let context_buffer_range =
-                                task_buffer_range.to_offset(&buffer_snapshot);
-                            let context_range = BufferOffset(context_buffer_range.start)
-                                ..BufferOffset(context_buffer_range.end);
-
-                            acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row))
-                                .or_insert_with(|| RunnableTasks {
-                                    templates: Vec::new(),
-                                    offset,
-                                    column: task_buffer_range.start.column,
-                                    extra_variables: HashMap::default(),
-                                    context_range,
-                                })
-                                .templates
-                                .push((kind, task.original_task().clone()));
-                        }
-
-                        acc
-                    })
-            }) else {
-                return;
-            };
-
-            let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
-                buffer.language_settings(cx).tasks.prefer_lsp
-            }) else {
-                return;
-            };
-
-            let rows = Self::runnable_rows(
-                project,
-                display_snapshot,
-                prefer_lsp && !lsp_tasks_by_rows.is_empty(),
-                new_rows,
-                cx.clone(),
-            )
-            .await;
-            editor
-                .update(cx, |editor, _| {
-                    editor.clear_tasks();
-                    for (key, mut value) in rows {
-                        if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) {
-                            value.templates.extend(lsp_tasks.templates);
-                        }
-
-                        editor.insert_tasks(key, value);
-                    }
-                    for (key, value) in lsp_tasks_by_rows {
-                        editor.insert_tasks(key, value);
-                    }
-                })
-                .ok();
-        })
-    }
-    fn fetch_runnable_ranges(
-        snapshot: &DisplaySnapshot,
-        range: Range<Anchor>,
-    ) -> Vec<(Range<MultiBufferOffset>, language::RunnableRange)> {
-        snapshot.buffer_snapshot().runnable_ranges(range).collect()
-    }
-
-    fn runnable_rows(
-        project: Entity<Project>,
-        snapshot: DisplaySnapshot,
-        prefer_lsp: bool,
-        runnable_ranges: Vec<(Range<MultiBufferOffset>, language::RunnableRange)>,
-        cx: AsyncWindowContext,
-    ) -> Task<Vec<((BufferId, BufferRow), RunnableTasks)>> {
-        cx.spawn(async move |cx| {
-            let mut runnable_rows = Vec::with_capacity(runnable_ranges.len());
-            for (run_range, mut runnable) in runnable_ranges {
-                let Some(tasks) = cx
-                    .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
-                    .ok()
-                else {
-                    continue;
-                };
-                let mut tasks = tasks.await;
-
-                if prefer_lsp {
-                    tasks.retain(|(task_kind, _)| {
-                        !matches!(task_kind, TaskSourceKind::Language { .. })
-                    });
-                }
-                if tasks.is_empty() {
-                    continue;
-                }
-
-                let point = run_range.start.to_point(&snapshot.buffer_snapshot());
-                let Some(row) = snapshot
-                    .buffer_snapshot()
-                    .buffer_line_for_row(MultiBufferRow(point.row))
-                    .map(|(_, range)| range.start.row)
-                else {
-                    continue;
-                };
-
-                let context_range =
-                    BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end);
-                runnable_rows.push((
-                    (runnable.buffer_id, row),
-                    RunnableTasks {
-                        templates: tasks,
-                        offset: snapshot.buffer_snapshot().anchor_before(run_range.start),
-                        context_range,
-                        column: point.column,
-                        extra_variables: runnable.extra_captures,
-                    },
-                ));
-            }
-            runnable_rows
-        })
-    }
-
-    fn templates_with_tags(
-        project: &Entity<Project>,
-        runnable: &mut Runnable,
-        cx: &mut App,
-    ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
-        let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
-            let (worktree_id, file) = project
-                .buffer_for_id(runnable.buffer, cx)
-                .and_then(|buffer| buffer.read(cx).file())
-                .map(|file| (file.worktree_id(cx), file.clone()))
-                .unzip();
-
-            (
-                project.task_store().read(cx).task_inventory().cloned(),
-                worktree_id,
-                file,
-            )
-        });
-
-        let tags = mem::take(&mut runnable.tags);
-        let language = runnable.language.clone();
-        cx.spawn(async move |cx| {
-            let mut templates_with_tags = Vec::new();
-            if let Some(inventory) = inventory {
-                for RunnableTag(tag) in tags {
-                    let new_tasks = inventory.update(cx, |inventory, cx| {
-                        inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx)
-                    });
-                    templates_with_tags.extend(new_tasks.await.into_iter().filter(
-                        move |(_, template)| {
-                            template.tags.iter().any(|source_tag| source_tag == &tag)
-                        },
-                    ));
-                }
-            }
-            templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned());
-
-            if let Some((leading_tag_source, _)) = templates_with_tags.first() {
-                // Strongest source wins; if we have worktree tag binding, prefer that to
-                // global and language bindings;
-                // if we have a global binding, prefer that to language binding.
-                let first_mismatch = templates_with_tags
-                    .iter()
-                    .position(|(tag_source, _)| tag_source != leading_tag_source);
-                if let Some(index) = first_mismatch {
-                    templates_with_tags.truncate(index);
-                }
-            }
-
-            templates_with_tags
-        })
-    }
-
     pub fn move_to_enclosing_bracket(
         &mut self,
         _: &MoveToEnclosingBracket,
@@ -17729,6 +17356,7 @@ impl Editor {
             &snapshot,
             selection.head(),
             Direction::Next,
+            true,
             window,
             cx,
         );
@@ -17739,14 +17367,15 @@ impl Editor {
         snapshot: &EditorSnapshot,
         position: Point,
         direction: Direction,
+        wrap_around: bool,
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
         let row = if direction == Direction::Next {
-            self.hunk_after_position(snapshot, position)
+            self.hunk_after_position(snapshot, position, wrap_around)
                 .map(|hunk| hunk.row_range.start)
         } else {
-            self.hunk_before_position(snapshot, position)
+            self.hunk_before_position(snapshot, position, wrap_around)
         };
 
         if let Some(row) = row {
@@ -17764,17 +17393,23 @@ impl Editor {
         &mut self,
         snapshot: &EditorSnapshot,
         position: Point,
+        wrap_around: bool,
     ) -> Option<MultiBufferDiffHunk> {
-        snapshot
+        let result = snapshot
             .buffer_snapshot()
             .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point())
-            .find(|hunk| hunk.row_range.start.0 > position.row)
-            .or_else(|| {
+            .find(|hunk| hunk.row_range.start.0 > position.row);
+
+        if wrap_around {
+            result.or_else(|| {
                 snapshot
                     .buffer_snapshot()
                     .diff_hunks_in_range(Point::zero()..position)
                     .find(|hunk| hunk.row_range.end.0 < position.row)
             })
+        } else {
+            result
+        }
     }
 
     fn go_to_prev_hunk(
@@ -17790,6 +17425,7 @@ impl Editor {
             &snapshot,
             selection.head(),
             Direction::Prev,
+            true,
             window,
             cx,
         );
@@ -17799,11 +17435,15 @@ impl Editor {
         &mut self,
         snapshot: &EditorSnapshot,
         position: Point,
+        wrap_around: bool,
     ) -> Option<MultiBufferRow> {
-        snapshot
-            .buffer_snapshot()
-            .diff_hunk_before(position)
-            .or_else(|| snapshot.buffer_snapshot().diff_hunk_before(Point::MAX))
+        let result = snapshot.buffer_snapshot().diff_hunk_before(position);
+
+        if wrap_around {
+            result.or_else(|| snapshot.buffer_snapshot().diff_hunk_before(Point::MAX))
+        } else {
+            result
+        }
     }
 
     fn go_to_next_change(
@@ -19549,7 +19189,7 @@ impl Editor {
     }
 
     pub fn diagnostics_enabled(&self) -> bool {
-        self.diagnostics_enabled && self.mode.is_full()
+        self.diagnostics_enabled && self.lsp_data_enabled()
     }
 
     pub fn inline_diagnostics_enabled(&self) -> bool {

crates/editor/src/editor_tests.rs 🔗

@@ -5,6 +5,7 @@ use crate::{
     edit_prediction_tests::FakeEditPredictionDelegate,
     element::StickyHeader,
     linked_editing_ranges::LinkedEditingRanges,
+    runnables::RunnableTasks,
     scroll::scroll_amount::ScrollAmount,
     test::{
         assert_text_with_selections, build_editor, editor_content_with_blocks,
@@ -76,6 +77,9 @@ fn display_ranges(editor: &Editor, cx: &mut Context<'_, Editor>) -> Vec<Range<Di
         .display_ranges(&editor.display_snapshot(cx))
 }
 
+#[cfg(any(test, feature = "test-support"))]
+pub mod property_test;
+
 #[gpui::test]
 fn test_edit_events(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -1864,6 +1868,56 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_beginning_of_line_single_line_editor(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let editor = cx.add_window(|window, cx| Editor::single_line(window, cx));
+
+    _ = editor.update(cx, |editor, window, cx| {
+        editor.set_text("  indented text", window, cx);
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10)
+            ]);
+        });
+
+        editor.move_to_beginning_of_line(
+            &MoveToBeginningOfLine {
+                stop_at_soft_wraps: true,
+                stop_at_indent: true,
+            },
+            window,
+            cx,
+        );
+        assert_eq!(
+            display_ranges(editor, cx),
+            &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
+        );
+    });
+
+    _ = editor.update(cx, |editor, window, cx| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10)
+            ]);
+        });
+
+        editor.select_to_beginning_of_line(
+            &SelectToBeginningOfLine {
+                stop_at_soft_wraps: true,
+                stop_at_indent: true,
+            },
+            window,
+            cx,
+        );
+        assert_eq!(
+            display_ranges(editor, cx),
+            &[DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 0)]
+        );
+    });
+}
+
 #[gpui::test]
 fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -6214,6 +6268,77 @@ async fn test_manipulate_text(cx: &mut TestAppContext) {
         «HeLlO, wOrLD!ˇ»
     "});
 
+    // Test that case conversions backed by `to_case` preserve leading/trailing whitespace.
+    cx.set_state(indoc! {"
+        «    hello worldˇ»
+    "});
+    cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx));
+    cx.assert_editor_state(indoc! {"
+        «    Hello Worldˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «    hello worldˇ»
+    "});
+    cx.update_editor(|e, window, cx| {
+        e.convert_to_upper_camel_case(&ConvertToUpperCamelCase, window, cx)
+    });
+    cx.assert_editor_state(indoc! {"
+        «    HelloWorldˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «    hello worldˇ»
+    "});
+    cx.update_editor(|e, window, cx| {
+        e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx)
+    });
+    cx.assert_editor_state(indoc! {"
+        «    helloWorldˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «    hello worldˇ»
+    "});
+    cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx));
+    cx.assert_editor_state(indoc! {"
+        «    hello_worldˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «    hello worldˇ»
+    "});
+    cx.update_editor(|e, window, cx| e.convert_to_kebab_case(&ConvertToKebabCase, window, cx));
+    cx.assert_editor_state(indoc! {"
+        «    hello-worldˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «    hello worldˇ»
+    "});
+    cx.update_editor(|e, window, cx| {
+        e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx)
+    });
+    cx.assert_editor_state(indoc! {"
+        «    Hello worldˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «    hello world\t\tˇ»
+    "});
+    cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx));
+    cx.assert_editor_state(indoc! {"
+        «    Hello World\t\tˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «    hello world\t\tˇ»
+    "});
+    cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx));
+    cx.assert_editor_state(indoc! {"
+        «    hello_world\t\tˇ»
+    "});
+
     // Test selections with `line_mode() = true`.
     cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true));
     cx.set_state(indoc! {"
@@ -12915,6 +13040,96 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_auto_formatter_skips_server_without_formatting(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_file(path!("/file.rs"), Default::default()).await;
+
+    let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    // First server: no formatting capability
+    let mut no_format_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            name: "no-format-server",
+            capabilities: lsp::ServerCapabilities {
+                completion_provider: Some(lsp::CompletionOptions::default()),
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+    );
+
+    // Second server: has formatting capability
+    let mut format_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            name: "format-server",
+            capabilities: lsp::ServerCapabilities {
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+
+    let _no_format_server = no_format_servers.next().await.unwrap();
+    let format_server = format_servers.next().await.unwrap();
+
+    format_server.set_request_handler::<lsp::request::Formatting, _, _>(
+        move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Uri::from_file_path(path!("/file.rs")).unwrap()
+            );
+            Ok(Some(vec![lsp::TextEdit::new(
+                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
+                ", ".to_string(),
+            )]))
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one, two\nthree\n"
+    );
+}
+
 #[gpui::test]
 async fn test_redo_after_noop_format(cx: &mut TestAppContext) {
     init_test(cx, |settings| {
@@ -24310,20 +24525,24 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) {
 
     editor.update_in(cx, |editor, window, cx| {
         let snapshot = editor.buffer().read(cx).snapshot(cx);
-        editor.tasks.insert(
-            (buffer.read(cx).remote_id(), 3),
+        editor.runnables.insert(
+            buffer.read(cx).remote_id(),
+            3,
+            buffer.read(cx).version(),
             RunnableTasks {
-                templates: vec![],
+                templates: Vec::new(),
                 offset: snapshot.anchor_before(MultiBufferOffset(43)),
                 column: 0,
                 extra_variables: HashMap::default(),
                 context_range: BufferOffset(43)..BufferOffset(85),
             },
         );
-        editor.tasks.insert(
-            (buffer.read(cx).remote_id(), 8),
+        editor.runnables.insert(
+            buffer.read(cx).remote_id(),
+            8,
+            buffer.read(cx).version(),
             RunnableTasks {
-                templates: vec![],
+                templates: Vec::new(),
                 offset: snapshot.anchor_before(MultiBufferOffset(86)),
                 column: 0,
                 extra_variables: HashMap::default(),
@@ -33464,3 +33683,66 @@ comment */ˇ»;"#},
         assert_text_with_selections(editor, indoc! {r#"let arr = [«1, 2, 3]ˇ»;"#}, cx);
     });
 }
+
+#[gpui::test]
+async fn test_restore_and_next(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        one
+        two
+        three
+        four
+        five
+        "#
+    .unindent();
+
+    cx.set_state(
+        &r#"
+        ONE
+        two
+        ˇTHREE
+        four
+        FIVE
+        "#
+        .unindent(),
+    );
+    cx.set_head_text(&diff_base);
+
+    cx.update_editor(|editor, window, cx| {
+        editor.set_expand_all_diff_hunks(cx);
+        editor.restore_and_next(&Default::default(), window, cx);
+    });
+    cx.run_until_parked();
+
+    cx.assert_state_with_diff(
+        r#"
+        - one
+        + ONE
+          two
+          three
+          four
+        - ˇfive
+        + FIVE
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, window, cx| {
+        editor.restore_and_next(&Default::default(), window, cx);
+    });
+    cx.run_until_parked();
+
+    cx.assert_state_with_diff(
+        r#"
+        - one
+        + ONE
+          two
+          three
+          four
+          ˇfive
+        "#
+        .unindent(),
+    );
+}

crates/editor/src/editor_tests/property_test.rs 🔗

@@ -0,0 +1,85 @@
+use proptest::prelude::*;
+
+use super::*;
+
+#[derive(Debug, Clone, proptest_derive::Arbitrary)]
+pub enum Direction {
+    Up,
+    Down,
+    Left,
+    Right,
+}
+
+#[derive(Debug, Clone, proptest_derive::Arbitrary)]
+pub enum TestAction {
+    #[proptest(weight = 4)]
+    Type(String),
+    Backspace {
+        #[proptest(strategy = "1usize..100")]
+        count: usize,
+    },
+    Move {
+        #[proptest(strategy = "1usize..100")]
+        count: usize,
+        direction: Direction,
+    },
+}
+
+impl Editor {
+    pub fn apply_test_action(
+        &mut self,
+        action: &TestAction,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        match action {
+            TestAction::Type(text) => self.insert(&text, window, cx),
+            TestAction::Backspace { count } => {
+                for _ in 0..*count {
+                    self.delete(&Default::default(), window, cx);
+                }
+            }
+            TestAction::Move { count, direction } => {
+                for _ in 0..*count {
+                    match direction {
+                        Direction::Up => self.move_up(&Default::default(), window, cx),
+                        Direction::Down => self.move_down(&Default::default(), window, cx),
+                        Direction::Left => self.move_left(&Default::default(), window, cx),
+                        Direction::Right => self.move_right(&Default::default(), window, cx),
+                    }
+                }
+            }
+        }
+    }
+}
+
+fn test_actions() -> impl Strategy<Value = Vec<TestAction>> {
+    proptest::collection::vec(any::<TestAction>(), 1..10)
+}
+
+#[gpui::property_test(config = ProptestConfig {cases: 100, ..Default::default()})]
+fn editor_property_test(
+    cx: &mut TestAppContext,
+    #[strategy = test_actions()] actions: Vec<TestAction>,
+) {
+    init_test(cx, |_| {});
+
+    let group_interval = Duration::from_millis(1);
+
+    let buffer = cx.new(|cx| {
+        let mut buf = language::Buffer::local("123456", cx);
+        buf.set_group_interval(group_interval);
+        buf
+    });
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx));
+
+    editor
+        .update(cx, |editor, window, cx| {
+            for action in actions {
+                editor.apply_test_action(&action, window, cx);
+            }
+        })
+        .unwrap();
+}

crates/editor/src/element.rs 🔗

@@ -41,18 +41,18 @@ use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatu
 use gpui::{
     Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
     Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
-    DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, FontWeight,
-    GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
-    KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
-    MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
-    Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
-    Size, StatefulInteractiveElement, Style, Styled, StyledText, TextAlign, TextRun,
+    DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, Font, FontId,
+    FontWeight, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement,
+    IsZero, KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton,
+    MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad,
+    ParentElement, Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine,
+    SharedString, Size, StatefulInteractiveElement, Style, Styled, StyledText, TextAlign, TextRun,
     TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop,
     linear_gradient, outline, pattern_slash, point, px, quad, relative, size, solid_background,
     transparent_black,
 };
 use itertools::Itertools;
-use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting};
+use language::{HighlightedText, IndentGuideSettings, language_settings::ShowWhitespaceSetting};
 use markdown::Markdown;
 use multi_buffer::{
     Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint,
@@ -98,7 +98,7 @@ use util::{RangeExt, ResultExt, debug_panic};
 use workspace::{
     CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel,
     Workspace,
-    item::{BreadcrumbText, Item, ItemBufferKind},
+    item::{Item, ItemBufferKind},
 };
 
 /// Determines what kinds of highlights should be applied to a lines background.
@@ -637,6 +637,7 @@ impl EditorElement {
         register_action(editor, window, Editor::accept_edit_prediction);
         register_action(editor, window, Editor::restore_file);
         register_action(editor, window, Editor::git_restore);
+        register_action(editor, window, Editor::restore_and_next);
         register_action(editor, window, Editor::apply_all_diff_hunks);
         register_action(editor, window, Editor::apply_selected_diff_hunks);
         register_action(editor, window, Editor::open_active_item_in_terminal);
@@ -1242,7 +1243,7 @@ impl EditorElement {
         let gutter_hitbox = &position_map.gutter_hitbox;
         let modifiers = event.modifiers;
         let text_hovered = text_hitbox.is_hovered(window);
-        let gutter_hovered = gutter_hitbox.bounds.contains(&event.position);
+        let gutter_hovered = gutter_hitbox.is_hovered(window);
         editor.set_gutter_hovered(gutter_hovered, cx);
         editor.show_mouse_cursor(cx);
 
@@ -1461,6 +1462,7 @@ impl EditorElement {
         if text_hovered {
             editor.update_hovered_link(
                 point_for_position,
+                Some(event.position),
                 &position_map.snapshot,
                 modifiers,
                 window,
@@ -1472,12 +1474,13 @@ impl EditorElement {
                     .snapshot
                     .buffer_snapshot()
                     .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left));
-                hover_at(editor, Some(anchor), window, cx);
+                hover_at(editor, Some(anchor), Some(event.position), window, cx);
                 Self::update_visible_cursor(editor, point, position_map, window, cx);
             } else {
                 editor.update_inlay_link_and_hover_points(
                     &position_map.snapshot,
                     point_for_position,
+                    Some(event.position),
                     modifiers.secondary(),
                     modifiers.shift,
                     window,
@@ -1486,7 +1489,7 @@ impl EditorElement {
             }
         } else {
             editor.hide_hovered_link(cx);
-            hover_at(editor, None, window, cx);
+            hover_at(editor, None, Some(event.position), window, cx);
         }
     }
 
@@ -3274,9 +3277,9 @@ impl EditorElement {
                 snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right);
 
             editor
-                .tasks
-                .iter()
-                .filter_map(|(_, tasks)| {
+                .runnables
+                .all_runnables()
+                .filter_map(|tasks| {
                     let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot());
                     if multibuffer_point < offset_range_start
                         || multibuffer_point > offset_range_end
@@ -7910,7 +7913,8 @@ impl EditorElement {
 }
 
 pub fn render_breadcrumb_text(
-    mut segments: Vec<BreadcrumbText>,
+    mut segments: Vec<HighlightedText>,
+    breadcrumb_font: Option<Font>,
     prefix: Option<gpui::AnyElement>,
     active_item: &dyn ItemHandle,
     multibuffer_header: bool,
@@ -7930,17 +7934,16 @@ pub fn render_breadcrumb_text(
     if suffix_start_ix > prefix_end_ix {
         segments.splice(
             prefix_end_ix..suffix_start_ix,
-            Some(BreadcrumbText {
+            Some(HighlightedText {
                 text: "⋯".into(),
-                highlights: None,
-                font: None,
+                highlights: vec![],
             }),
         );
     }
 
     let highlighted_segments = segments.into_iter().enumerate().map(|(index, segment)| {
         let mut text_style = window.text_style();
-        if let Some(ref font) = segment.font {
+        if let Some(font) = &breadcrumb_font {
             text_style.font_family = font.family.clone();
             text_style.font_features = font.features.clone();
             text_style.font_style = font.style;
@@ -7957,7 +7960,7 @@ pub fn render_breadcrumb_text(
         }
 
         StyledText::new(segment.text.replace('\n', " "))
-            .with_default_highlights(&text_style, segment.highlights.unwrap_or_default())
+            .with_default_highlights(&text_style, segment.highlights)
             .into_any()
     });
 
@@ -8067,13 +8070,13 @@ pub fn render_breadcrumb_text(
 }
 
 fn apply_dirty_filename_style(
-    segment: &BreadcrumbText,
+    segment: &HighlightedText,
     text_style: &gpui::TextStyle,
     cx: &App,
 ) -> Option<gpui::AnyElement> {
     let text = segment.text.replace('\n', " ");
 
-    let filename_position = std::path::Path::new(&segment.text)
+    let filename_position = std::path::Path::new(segment.text.as_ref())
         .file_name()
         .and_then(|f| {
             let filename_str = f.to_string_lossy();
@@ -8443,8 +8446,12 @@ pub(crate) fn render_buffer_header(
                                         el.child(Icon::new(IconName::FileLock).color(Color::Muted))
                                     })
                                     .when_some(breadcrumbs, |then, breadcrumbs| {
+                                        let font = theme::ThemeSettings::get_global(cx)
+                                            .buffer_font
+                                            .clone();
                                         then.child(render_breadcrumb_text(
                                             breadcrumbs,
+                                            Some(font),
                                             None,
                                             editor_handle,
                                             true,

crates/editor/src/folding_ranges.rs 🔗

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

crates/editor/src/hover_links.rs 🔗

@@ -4,7 +4,7 @@ use crate::{
     HighlightKey, Navigated, PointForPosition, SelectPhase,
     editor_settings::GoToDefinitionFallback, scroll::ScrollAmount,
 };
-use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px};
+use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Pixels, Task, Window, px};
 use language::{Bias, ToOffset};
 use linkify::{LinkFinder, LinkKind};
 use lsp::LanguageServerId;
@@ -113,13 +113,14 @@ impl Editor {
     pub(crate) fn update_hovered_link(
         &mut self,
         point_for_position: PointForPosition,
+        mouse_position: Option<gpui::Point<Pixels>>,
         snapshot: &EditorSnapshot,
         modifiers: Modifiers,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&modifiers, cx);
-        if !hovered_link_modifier || self.has_pending_selection() {
+        if !hovered_link_modifier || self.has_pending_selection() || self.mouse_cursor_hidden {
             self.hide_hovered_link(cx);
             return;
         }
@@ -138,6 +139,7 @@ impl Editor {
                 self.update_inlay_link_and_hover_points(
                     snapshot,
                     point_for_position,
+                    mouse_position,
                     hovered_link_modifier,
                     modifiers.shift,
                     window,
@@ -782,7 +784,7 @@ fn surrounding_filename(
 mod tests {
     use super::*;
     use crate::{
-        DisplayPoint,
+        DisplayPoint, HideMouseCursorOrigin,
         display_map::ToDisplayPoint,
         editor_tests::init_test,
         inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels},
@@ -1362,6 +1364,82 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_hover_preconditions(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        macro_rules! assert_no_highlight {
+            ($cx:expr) => {
+                // No highlight
+                $cx.update_editor(|editor, window, cx| {
+                    assert!(
+                        editor
+                            .snapshot(window, cx)
+                            .text_highlight_ranges(HighlightKey::HoveredLinkState)
+                            .unwrap_or_default()
+                            .1
+                            .is_empty()
+                    );
+                });
+            };
+        }
+
+        // No link
+        cx.set_state(indoc! {"
+            Let's test a [complex](https://zed.dev/channel/) caseˇ.
+        "});
+        assert_no_highlight!(cx);
+
+        // No modifier
+        let screen_coord = cx.pixel_position(indoc! {"
+            Let's test a [complex](https://zed.dev/channel/ˇ) case.
+            "});
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
+        assert_no_highlight!(cx);
+
+        // Modifier active
+        let screen_coord = cx.pixel_position(indoc! {"
+            Let's test a [complex](https://zed.dev/channeˇl/) case.
+            "});
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights(
+            HighlightKey::HoveredLinkState,
+            indoc! {"
+            Let's test a [complex](«https://zed.dev/channel/ˇ») case.
+        "},
+        );
+
+        // Cursor hidden with secondary key
+        let screen_coord = cx.pixel_position(indoc! {"
+            Let's test a [complex](https://zed.dev/ˇchannel/) case.
+            "});
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
+        cx.update_editor(|editor, _, cx| {
+            editor.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
+        });
+        cx.simulate_modifiers_change(Modifiers::secondary_key());
+        assert_no_highlight!(cx);
+
+        // Cursor active again
+        let screen_coord = cx.pixel_position(indoc! {"
+            Let's test a [complex](https://ˇzed.dev/channel/) case.
+            "});
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights(
+            HighlightKey::HoveredLinkState,
+            indoc! {"
+            Let's test a [complex](«https://zed.dev/channel/ˇ») case.
+        "},
+        );
+    }
+
     #[gpui::test]
     async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
         init_test(cx, |_| {});

crates/editor/src/hover_popover.rs 🔗

@@ -8,10 +8,10 @@ use crate::{
 };
 use anyhow::Context as _;
 use gpui::{
-    AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla,
+    AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
     StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement,
-    Window, div, px,
+    Window, canvas, div, px,
 };
 use itertools::Itertools;
 use language::{DiagnosticEntry, Language, LanguageRegistry};
@@ -20,7 +20,10 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
 use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
 use settings::Settings;
-use std::{borrow::Cow, cell::RefCell};
+use std::{
+    borrow::Cow,
+    cell::{Cell, RefCell},
+};
 use std::{ops::Range, sync::Arc, time::Duration};
 use std::{path::PathBuf, rc::Rc};
 use theme::ThemeSettings;
@@ -45,6 +48,7 @@ pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Conte
 pub fn hover_at(
     editor: &mut Editor,
     anchor: Option<Anchor>,
+    mouse_position: Option<gpui::Point<Pixels>>,
     window: &mut Window,
     cx: &mut Context<Editor>,
 ) {
@@ -52,10 +56,32 @@ pub fn hover_at(
         if show_keyboard_hover(editor, window, cx) {
             return;
         }
+
         if let Some(anchor) = anchor {
+            editor.hover_state.hiding_delay_task = None;
+            editor.hover_state.closest_mouse_distance = None;
             show_hover(editor, anchor, false, window, cx);
         } else {
-            hide_hover(editor, cx);
+            let mut getting_closer = false;
+            if let Some(mouse_position) = mouse_position {
+                getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position);
+            }
+
+            // If we are moving away and a timer is already running, just let it count down.
+            if !getting_closer && editor.hover_state.hiding_delay_task.is_some() {
+                return;
+            }
+
+            // If we are moving closer, or if no timer is running at all, start/restart the 300ms timer.
+            let delay = Duration::from_millis(300u64);
+            let task = cx.spawn(async move |this, cx| {
+                cx.background_executor().timer(delay).await;
+                this.update(cx, |editor, cx| {
+                    hide_hover(editor, cx);
+                })
+                .ok();
+            });
+            editor.hover_state.hiding_delay_task = Some(task);
         }
     }
 }
@@ -156,6 +182,9 @@ pub fn hover_at_inlay(
 
         let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
 
+        editor.hover_state.hiding_delay_task = None;
+        editor.hover_state.closest_mouse_distance = None;
+
         let task = cx.spawn_in(window, async move |this, cx| {
             async move {
                 cx.background_executor()
@@ -187,6 +216,7 @@ pub fn hover_at_inlay(
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(false)),
                     anchor: None,
+                    last_bounds: Rc::new(Cell::new(None)),
                     _subscription: subscription,
                 };
 
@@ -216,6 +246,8 @@ pub fn hide_hover(editor: &mut Editor, cx: &mut Context<Editor>) -> bool {
 
     editor.hover_state.info_task = None;
     editor.hover_state.triggered_from = None;
+    editor.hover_state.hiding_delay_task = None;
+    editor.hover_state.closest_mouse_distance = None;
 
     editor.clear_background_highlights(HighlightKey::HoverState, cx);
 
@@ -254,6 +286,9 @@ fn show_hover(
         .map(|project| project.read(cx).languages().clone());
     let provider = editor.semantics_provider.clone()?;
 
+    editor.hover_state.hiding_delay_task = None;
+    editor.hover_state.closest_mouse_distance = None;
+
     if !ignore_timeout {
         if same_info_hover(editor, &snapshot, anchor)
             || same_diagnostic_hover(editor, &snapshot, anchor)
@@ -398,6 +433,7 @@ fn show_hover(
                     background_color,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
                     anchor,
+                    last_bounds: Rc::new(Cell::new(None)),
                     _subscription: subscription,
                 })
             } else {
@@ -466,6 +502,7 @@ fn show_hover(
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
                     anchor: Some(anchor),
+                    last_bounds: Rc::new(Cell::new(None)),
                     _subscription: subscription,
                 })
             }
@@ -507,6 +544,7 @@ fn show_hover(
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
                     anchor: Some(anchor),
+                    last_bounds: Rc::new(Cell::new(None)),
                     _subscription: subscription,
                 });
             }
@@ -778,6 +816,8 @@ pub struct HoverState {
     pub diagnostic_popover: Option<DiagnosticPopover>,
     pub triggered_from: Option<Anchor>,
     pub info_task: Option<Task<Option<()>>>,
+    pub closest_mouse_distance: Option<Pixels>,
+    pub hiding_delay_task: Option<Task<()>>,
 }
 
 impl HoverState {
@@ -785,6 +825,60 @@ impl HoverState {
         !self.info_popovers.is_empty() || self.diagnostic_popover.is_some()
     }
 
+    pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point<Pixels>) -> bool {
+        if !self.visible() {
+            return false;
+        }
+
+        let mut popover_bounds = Vec::new();
+        for info_popover in &self.info_popovers {
+            if let Some(bounds) = info_popover.last_bounds.get() {
+                popover_bounds.push(bounds);
+            }
+        }
+        if let Some(diagnostic_popover) = &self.diagnostic_popover {
+            if let Some(bounds) = diagnostic_popover.last_bounds.get() {
+                popover_bounds.push(bounds);
+            }
+        }
+
+        if popover_bounds.is_empty() {
+            return false;
+        }
+
+        let distance = popover_bounds
+            .iter()
+            .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds))
+            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
+            .unwrap_or(px(f32::MAX));
+
+        if let Some(closest_distance) = self.closest_mouse_distance {
+            if distance > closest_distance + px(4.0) {
+                return false;
+            }
+        }
+
+        self.closest_mouse_distance =
+            Some(distance.min(self.closest_mouse_distance.unwrap_or(distance)));
+        true
+    }
+
+    fn distance_from_point_to_bounds(
+        &self,
+        point: gpui::Point<Pixels>,
+        bounds: Bounds<Pixels>,
+    ) -> Pixels {
+        let center_x = bounds.origin.x + bounds.size.width / 2.;
+        let center_y = bounds.origin.y + bounds.size.height / 2.;
+        let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.)
+            .max(px(0.0))
+            .into();
+        let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.)
+            .max(px(0.0))
+            .into();
+        px((dx.powi(2) + dy.powi(2)).sqrt())
+    }
+
     pub(crate) fn render(
         &mut self,
         snapshot: &EditorSnapshot,
@@ -887,6 +981,7 @@ pub struct InfoPopover {
     pub scroll_handle: ScrollHandle,
     pub keyboard_grace: Rc<RefCell<bool>>,
     pub anchor: Option<Anchor>,
+    pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
     _subscription: Option<Subscription>,
 }
 
@@ -898,13 +993,36 @@ impl InfoPopover {
         cx: &mut Context<Editor>,
     ) -> AnyElement {
         let keyboard_grace = Rc::clone(&self.keyboard_grace);
+        let this = cx.entity().downgrade();
+        let bounds_cell = self.last_bounds.clone();
         div()
             .id("info_popover")
             .occlude()
             .elevation_2(cx)
+            .child(
+                canvas(
+                    {
+                        move |bounds, _window, _cx| {
+                            bounds_cell.set(Some(bounds));
+                        }
+                    },
+                    |_, _, _, _| {},
+                )
+                .absolute()
+                .size_full(),
+            )
             // Prevent a mouse down/move on the popover from being propagated to the editor,
             // because that would dismiss the popover.
-            .on_mouse_move(|_, _, cx| cx.stop_propagation())
+            .on_mouse_move({
+                move |_, _, cx: &mut App| {
+                    this.update(cx, |editor, _| {
+                        editor.hover_state.closest_mouse_distance = Some(px(0.0));
+                        editor.hover_state.hiding_delay_task = None;
+                    })
+                    .ok();
+                    cx.stop_propagation()
+                }
+            })
             .on_mouse_down(MouseButton::Left, move |_, _, cx| {
                 let mut keyboard_grace = keyboard_grace.borrow_mut();
                 *keyboard_grace = false;
@@ -957,6 +1075,7 @@ pub struct DiagnosticPopover {
     background_color: Hsla,
     pub keyboard_grace: Rc<RefCell<bool>>,
     pub anchor: Anchor,
+    pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
     _subscription: Subscription,
     pub scroll_handle: ScrollHandle,
 }
@@ -970,10 +1089,23 @@ impl DiagnosticPopover {
     ) -> AnyElement {
         let keyboard_grace = Rc::clone(&self.keyboard_grace);
         let this = cx.entity().downgrade();
+        let bounds_cell = self.last_bounds.clone();
         div()
             .id("diagnostic")
             .occlude()
             .elevation_2_borderless(cx)
+            .child(
+                canvas(
+                    {
+                        move |bounds, _window, _cx| {
+                            bounds_cell.set(Some(bounds));
+                        }
+                    },
+                    |_, _, _, _| {},
+                )
+                .absolute()
+                .size_full(),
+            )
             // Don't draw the background color if the theme
             // allows transparent surfaces.
             .when(theme_is_transparent(cx), |this| {
@@ -981,7 +1113,17 @@ impl DiagnosticPopover {
             })
             // Prevent a mouse move on the popover from being propagated to the editor,
             // because that would dismiss the popover.
-            .on_mouse_move(|_, _, cx| cx.stop_propagation())
+            .on_mouse_move({
+                let this = this.clone();
+                move |_, _, cx: &mut App| {
+                    this.update(cx, |editor, _| {
+                        editor.hover_state.closest_mouse_distance = Some(px(0.0));
+                        editor.hover_state.hiding_delay_task = None;
+                    })
+                    .ok();
+                    cx.stop_propagation()
+                }
+            })
             // Prevent a mouse down on the popover from being propagated to the editor,
             // because that would move the cursor.
             .on_mouse_down(MouseButton::Left, move |_, _, cx| {
@@ -1151,7 +1293,7 @@ mod tests {
             let anchor = snapshot
                 .buffer_snapshot()
                 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
-            hover_at(editor, Some(anchor), window, cx)
+            hover_at(editor, Some(anchor), None, window, cx)
         });
         assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
 
@@ -1251,7 +1393,7 @@ mod tests {
             let anchor = snapshot
                 .buffer_snapshot()
                 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
-            hover_at(editor, Some(anchor), window, cx)
+            hover_at(editor, Some(anchor), None, window, cx)
         });
         cx.background_executor
             .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
@@ -1289,7 +1431,7 @@ mod tests {
             let anchor = snapshot
                 .buffer_snapshot()
                 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
-            hover_at(editor, Some(anchor), window, cx)
+            hover_at(editor, Some(anchor), None, window, cx)
         });
         assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
 
@@ -1343,7 +1485,7 @@ mod tests {
             let anchor = snapshot
                 .buffer_snapshot()
                 .anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
-            hover_at(editor, Some(anchor), window, cx)
+            hover_at(editor, Some(anchor), None, window, cx)
         });
         cx.background_executor
             .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
@@ -1752,6 +1894,7 @@ mod tests {
             editor.update_inlay_link_and_hover_points(
                 &editor.snapshot(window, cx),
                 new_type_hint_part_hover_position,
+                None,
                 true,
                 false,
                 window,
@@ -1822,6 +1965,7 @@ mod tests {
             editor.update_inlay_link_and_hover_points(
                 &editor.snapshot(window, cx),
                 new_type_hint_part_hover_position,
+                None,
                 true,
                 false,
                 window,
@@ -1877,6 +2021,7 @@ mod tests {
             editor.update_inlay_link_and_hover_points(
                 &editor.snapshot(window, cx),
                 struct_hint_part_hover_position,
+                None,
                 true,
                 false,
                 window,

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

@@ -7,7 +7,7 @@ use std::{
 use clock::Global;
 use collections::{HashMap, HashSet};
 use futures::future::join_all;
-use gpui::{App, Entity, Task};
+use gpui::{App, Entity, Pixels, Task};
 use itertools::Itertools;
 use language::{
     BufferRow,
@@ -292,7 +292,7 @@ impl Editor {
         reason: InlayHintRefreshReason,
         cx: &mut Context<Self>,
     ) {
-        if !self.mode().is_full() || self.inlay_hints.is_none() {
+        if !self.lsp_data_enabled() || self.inlay_hints.is_none() {
             return;
         }
         let Some(semantics_provider) = self.semantics_provider() else {
@@ -569,6 +569,7 @@ impl Editor {
         &mut self,
         snapshot: &EditorSnapshot,
         point_for_position: PointForPosition,
+        mouse_position: Option<gpui::Point<Pixels>>,
         secondary_held: bool,
         shift_held: bool,
         window: &mut Window,
@@ -748,7 +749,7 @@ impl Editor {
             self.hide_hovered_link(cx)
         }
         if !hover_updated {
-            hover_popover::hover_at(self, None, window, cx);
+            hover_popover::hover_at(self, None, mouse_position, window, cx);
         }
     }
 

crates/editor/src/items.rs 🔗

@@ -14,12 +14,12 @@ use fs::MTime;
 use futures::future::try_join_all;
 use git::status::GitSummary;
 use gpui::{
-    AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, IntoElement,
-    ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
+    AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, Font,
+    IntoElement, ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
 };
 use language::{
-    Bias, Buffer, BufferRow, CharKind, CharScopeContext, LocalFile, Point, SelectionGoal,
-    proto::serialize_anchor as serialize_text_anchor,
+    Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, Point,
+    SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
 };
 use lsp::DiagnosticSeverity;
 use multi_buffer::MultiBufferOffset;
@@ -56,7 +56,7 @@ use workspace::{
 };
 use workspace::{
     OpenVisible, Pane, WorkspaceSettings,
-    item::{BreadcrumbText, FollowEvent, ProjectItemKind},
+    item::{FollowEvent, ProjectItemKind},
     searchable::SearchOptions,
 };
 use zed_actions::preview::{
@@ -981,9 +981,10 @@ impl Item for Editor {
     }
 
     // In a non-singleton case, the breadcrumbs are actually shown on sticky file headers of the multibuffer.
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         if self.buffer.read(cx).is_singleton() {
-            self.breadcrumbs_inner(cx)
+            let font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
+            Some((self.breadcrumbs_inner(cx)?, Some(font)))
         } else {
             None
         }

crates/editor/src/linked_editing_ranges.rs 🔗

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

crates/editor/src/movement.rs 🔗

@@ -408,7 +408,7 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
     let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
 
     find_preceding_boundary_display_point(map, point, FindRange::MultiLine, &mut |left, right| {
-        is_subword_start(left, right, &classifier) || left == '\n'
+        is_subword_start(left, right, &classifier) || left == '\n' || right == '\n'
     })
 }
 
@@ -431,6 +431,7 @@ pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) ->
     let is_word_start = classifier.kind(left) != classifier.kind(right) && !right.is_whitespace();
     let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
         || left == '_' && right != '_'
+        || left != '_' && right == '_'
         || left.is_lowercase() && right.is_uppercase();
     is_word_start || is_subword_start
 }
@@ -484,7 +485,7 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
 
     find_boundary(map, point, FindRange::MultiLine, &mut |left, right| {
-        is_subword_end(left, right, &classifier) || right == '\n'
+        is_subword_end(left, right, &classifier) || left == '\n' || right == '\n'
     })
 }
 
@@ -519,6 +520,7 @@ pub fn is_subword_end(left: char, right: char, classifier: &CharClassifier) -> b
 fn is_subword_boundary_end(left: char, right: char, classifier: &CharClassifier) -> bool {
     classifier.is_word('-') && left != '-' && right == '-'
         || left != '_' && right == '_'
+        || left == '_' && right != '_'
         || left.is_lowercase() && right.is_uppercase()
 }
 
@@ -973,10 +975,10 @@ mod tests {
         }
 
         // Subword boundaries are respected
-        assert("lorem_ˇipˇsum", cx);
+        assert("loremˇ_ˇipsum", cx);
         assert("lorem_ˇipsumˇ", cx);
-        assert("ˇlorem_ˇipsum", cx);
-        assert("lorem_ˇipsum_ˇdolor", cx);
+        assert("ˇloremˇ_ipsum", cx);
+        assert("lorem_ˇipsumˇ_dolor", cx);
         assert("loremˇIpˇsum", cx);
         assert("loremˇIpsumˇ", cx);
 
@@ -1156,10 +1158,10 @@ mod tests {
         }
 
         // Subword boundaries are respected
-        assert("loˇremˇ_ipsum", cx);
+        assert("loremˇ_ˇipsum", cx);
         assert("ˇloremˇ_ipsum", cx);
-        assert("loremˇ_ipsumˇ", cx);
-        assert("loremˇ_ipsumˇ_dolor", cx);
+        assert("loremˇ_ˇipsum", cx);
+        assert("lorem_ˇipsumˇ_dolor", cx);
         assert("loˇremˇIpsum", cx);
         assert("loremˇIpsumˇDolor", cx);
 
@@ -1172,7 +1174,7 @@ mod tests {
         assert("loremˇ    ipsumˇ   ", cx);
         assert("loremˇ-ˇipsum", cx);
         assert("loremˇ#$@-ˇipsum", cx);
-        assert("loremˇ_ipsumˇ", cx);
+        assert("loremˇ_ˇipsum", cx);
         assert(" ˇbcˇΔ", cx);
         assert(" abˇ——ˇcd", cx);
     }

crates/editor/src/runnables.rs 🔗

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

crates/editor/src/semantic_tokens.rs 🔗

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

crates/editor/src/split.rs 🔗

@@ -6,9 +6,11 @@ use std::{
 use buffer_diff::{BufferDiff, BufferDiffSnapshot};
 use collections::HashMap;
 
-use gpui::{Action, AppContext as _, Entity, EventEmitter, Focusable, Subscription, WeakEntity};
+use gpui::{
+    Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Subscription, WeakEntity,
+};
 use itertools::Itertools;
-use language::{Buffer, Capability};
+use language::{Buffer, Capability, HighlightedText};
 use multi_buffer::{
     Anchor, BufferOffset, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer,
     MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey,
@@ -29,7 +31,7 @@ use crate::{
 };
 use workspace::{
     ActivatePaneLeft, ActivatePaneRight, Item, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemBufferKind, ItemEvent, SaveOptions, TabContentParams},
+    item::{ItemBufferKind, ItemEvent, SaveOptions, TabContentParams},
     searchable::{SearchEvent, SearchToken, SearchableItem, SearchableItemHandle},
 };
 
@@ -446,6 +448,9 @@ impl SplittableEditor {
             let mut editor =
                 Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx);
             editor.set_expand_all_diff_hunks(cx);
+            editor.disable_runnables();
+            editor.disable_diagnostics(cx);
+            editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx);
             editor
         });
         // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs
@@ -1165,8 +1170,8 @@ impl SplittableEditor {
                 let lhs_ranges: Vec<ExcerptRange<Point>> = rhs_multibuffer
                     .excerpts_for_buffer(main_buffer_snapshot.remote_id(), cx)
                     .into_iter()
-                    .filter(|(id, _)| rhs_excerpt_ids.contains(id))
-                    .map(|(_, excerpt_range)| {
+                    .filter(|(id, _, _)| rhs_excerpt_ids.contains(id))
+                    .map(|(_, _, excerpt_range)| {
                         let to_base_text = |range: Range<Point>| {
                             let start = diff_snapshot
                                 .buffer_point_to_base_text_range(
@@ -1850,13 +1855,28 @@ impl Item for SplittableEditor {
         self.rhs_editor.read(cx).breadcrumb_location(cx)
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.rhs_editor.read(cx).breadcrumbs(cx)
     }
 
     fn pixel_position_of_cursor(&self, cx: &App) -> Option<gpui::Point<gpui::Pixels>> {
         self.focused_editor().read(cx).pixel_position_of_cursor(cx)
     }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: std::any::TypeId,
+        self_handle: &'a Entity<Self>,
+        _: &'a App,
+    ) -> Option<gpui::AnyEntity> {
+        if type_id == std::any::TypeId::of::<Self>() {
+            Some(self_handle.clone().into())
+        } else if type_id == std::any::TypeId::of::<Editor>() {
+            Some(self.rhs_editor.clone().into())
+        } else {
+            None
+        }
+    }
 }
 
 impl SearchableItem for SplittableEditor {
@@ -2064,7 +2084,7 @@ impl Render for SplittableEditor {
 
 #[cfg(test)]
 mod tests {
-    use std::sync::Arc;
+    use std::{any::TypeId, sync::Arc};
 
     use buffer_diff::BufferDiff;
     use collections::{HashMap, HashSet};
@@ -2080,14 +2100,14 @@ mod tests {
     use settings::{DiffViewStyle, SettingsStore};
     use ui::{VisualContext as _, div, px};
     use util::rel_path::rel_path;
-    use workspace::MultiWorkspace;
+    use workspace::{Item, MultiWorkspace};
 
-    use crate::SplittableEditor;
     use crate::display_map::{
         BlockPlacement, BlockProperties, BlockStyle, Crease, FoldPlaceholder,
     };
     use crate::inlays::Inlay;
     use crate::test::{editor_content_with_blocks_and_width, set_block_content_for_tests};
+    use crate::{Editor, SplittableEditor};
     use multi_buffer::MultiBufferOffset;
 
     async fn init_test(
@@ -6025,4 +6045,17 @@ mod tests {
 
         cx.run_until_parked();
     }
+
+    #[gpui::test]
+    async fn test_act_as_type(cx: &mut gpui::TestAppContext) {
+        let (splittable_editor, cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
+        let editor = splittable_editor.read_with(cx, |editor, cx| {
+            editor.act_as_type(TypeId::of::<Editor>(), &splittable_editor, cx)
+        });
+
+        assert!(
+            editor.is_some(),
+            "SplittableEditor should be able to act as Editor"
+        );
+    }
 }

crates/editor/src/tasks.rs 🔗

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

crates/eval/src/eval.rs 🔗

@@ -429,7 +429,7 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
     let extension_host_proxy = ExtensionHostProxy::global(cx);
     debug_adapter_extension::init(extension_host_proxy.clone(), cx);
     language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone());
-    language_model::init(client.clone(), cx);
+    language_model::init(user_store.clone(), client.clone(), cx);
     language_models::init(user_store.clone(), client.clone(), cx);
     languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx);
     prompt_store::init(cx);

crates/eval_cli/Cargo.toml 🔗

@@ -0,0 +1,50 @@
+[package]
+name = "eval_cli"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "eval-cli"
+path = "src/main.rs"
+
+[dependencies]
+acp_thread.workspace = true
+agent.workspace = true
+agent-client-protocol.workspace = true
+agent_ui.workspace = true
+anyhow.workspace = true
+clap.workspace = true
+client.workspace = true
+ctrlc = { version = "3.5", features = ["termination"] }
+debug_adapter_extension.workspace = true
+env_logger.workspace = true
+extension.workspace = true
+feature_flags.workspace = true
+fs.workspace = true
+futures.workspace = true
+gpui.workspace = true
+gpui_platform.workspace = true
+gpui_tokio.workspace = true
+language.workspace = true
+language_extension.workspace = true
+language_model.workspace = true
+language_models.workspace = true
+languages = { workspace = true, features = ["load-grammars"] }
+node_runtime.workspace = true
+paths.workspace = true
+project.workspace = true
+prompt_store.workspace = true
+release_channel.workspace = true
+reqwest_client.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+shellexpand.workspace = true
+terminal_view.workspace = true
+util.workspace = true
+watch.workspace = true

crates/eval_cli/Dockerfile 🔗

@@ -0,0 +1,62 @@
+# Build eval-cli for Linux.
+#
+# Usage (from the zed repo root):
+#   docker build --platform linux/amd64 -f crates/eval_cli/Dockerfile -t eval-cli-builder .
+#   docker cp "$(docker create eval-cli-builder)":/eval-cli ./target/eval-cli
+#
+# Or use the helper script:
+#   crates/eval_cli/script/build-linux
+
+FROM rust:1.93.1-bookworm AS builder
+
+WORKDIR /app
+
+# Install build dependencies (subset of script/linux needed for headless GPUI).
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    cmake \
+    clang \
+    g++ \
+    libasound2-dev \
+    libfontconfig-dev \
+    libgit2-dev \
+    libglib2.0-dev \
+    libssl-dev \
+    libwayland-dev \
+    libx11-xcb-dev \
+    libxkbcommon-x11-dev \
+    libzstd-dev \
+    libsqlite3-dev \
+    build-essential \
+    curl \
+    && rm -rf /var/lib/apt/lists/*
+
+# Install wild linker for faster linking (built from source to match bookworm's glibc).
+RUN cargo install --locked wild-linker --version 0.8.0 --root /usr/local
+
+# Download WASI SDK (needed by some dependencies).
+ARG TARGETARCH
+RUN mkdir -p /app/target && \
+    WASI_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "x86_64") && \
+    curl -L "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-${WASI_ARCH}-linux.tar.gz" \
+    | tar -xz -C /app/target && \
+    mv /app/target/wasi-sdk-25.0-${WASI_ARCH}-linux /app/target/wasi-sdk
+
+# Pre-install the toolchain specified in rust-toolchain.toml so it is cached.
+RUN rustup toolchain install 1.93 --profile minimal \
+    --component rustfmt --component clippy --component rust-analyzer --component rust-src \
+    --target wasm32-wasip2 --target wasm32-unknown-unknown --target x86_64-unknown-linux-musl
+
+COPY . .
+
+ENV CC=clang CXX=clang++
+ENV RUSTFLAGS="-C linker=clang -C link-arg=--ld-path=wild"
+
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git \
+    --mount=type=cache,target=/app/target \
+    cargo build --release --package eval_cli && \
+    cp /app/target/release/eval-cli /eval-cli && \
+    strip /eval-cli
+
+FROM scratch
+COPY --from=builder /eval-cli /eval-cli

crates/eval_cli/Dockerfile.dockerignore 🔗

@@ -0,0 +1,21 @@
+.git
+.github
+**/.gitignore
+**/.gitkeep
+.gitattributes
+.mailmap
+**/target
+zed.xcworkspace
+.DS_Store
+compose.yml
+plugins/bin
+script/node_modules
+styles/node_modules
+crates/collab/static/styles.css
+vendor/bin
+assets/themes/
+**/jobs
+
+**/*.egg-info
+**/__pycache__
+**/.venv

crates/eval_cli/README.md 🔗

@@ -0,0 +1,108 @@
+# eval-cli
+
+Headless CLI binary for running Zed's agent in evaluation/benchmark
+environments. Designed to work inside containerized environments like
+[Harbor](https://harborframework.com/) where the repository is already
+checked out and API keys are provided via environment variables.
+
+Uses the same `NativeAgent` + `AcpThread` pipeline as the production Zed
+editor — full agentic loop with tool calls, subagents, and retries, just
+without a GUI.
+
+## Building
+
+### Native (for local testing on the same OS)
+
+```
+cargo build --release -p eval_cli
+```
+
+### Cross-compile for Linux x86_64 (from macOS or other hosts)
+
+Harbor containers run Linux x86_64. Use the Docker-based build script:
+
+```
+crates/eval_cli/script/build-linux
+```
+
+This produces `target/eval-cli` (an x86_64 Linux ELF binary). You can
+also specify a custom output path:
+
+```
+crates/eval_cli/script/build-linux --output ~/bin/eval-cli-linux
+```
+
+## Standalone usage
+
+```
+eval-cli \
+  --workdir /testbed \
+  --model anthropic/claude-sonnet-4-6-latest \
+  --instruction "Fix the bug described in..." \
+  --timeout 600 \
+  --output-dir /logs/agent
+```
+
+Reads API keys from environment variables (`ANTHROPIC_API_KEY`,
+`OPENAI_API_KEY`, etc.). Writes `result.json`, `thread.md`, and
+`thread.json` to the output directory.
+
+### Exit codes
+
+| Code | Meaning                            |
+| ---- | ---------------------------------- |
+| 0    | Agent finished                     |
+| 1    | Error (model/auth/runtime failure) |
+| 2    | Timeout                            |
+| 3    | Interrupted (SIGTERM/SIGINT)       |
+
+## Harbor integration
+
+The `zed_eval/` directory contains a Python package that
+implements Harbor's `BaseInstalledAgent` interface, allowing eval-cli to
+be used with `--agent-import-path` without modifying Harbor's source code.
+
+### Setup
+
+```
+pip install -e crates/eval_cli/harbor/
+```
+
+### Running with a local binary
+
+Build for Linux first, then pass the binary path:
+
+```
+crates/eval_cli/script/build-linux
+
+harbor run -d "swebench_verified@latest" \
+  --agent-import-path zed_eval.agent:ZedAgent \
+  --ae binary_path=target/eval-cli \
+  -m anthropic/claude-sonnet-4-6-latest
+```
+
+The agent uploads the binary into the container during setup — no
+download URL needed during local iteration.
+
+### Running with a download URL
+
+For CI or when the binary is hosted somewhere:
+
+```
+harbor run -d "swebench_verified@latest" \
+  --agent-import-path zed_eval.agent:ZedAgent \
+  --ak download_url=https://example.com/eval-cli \
+  -m anthropic/claude-sonnet-4-6-latest
+```
+
+### Setting a timeout
+
+Pass `EVAL_CLI_TIMEOUT` via `--ae`:
+
+```
+harbor run -d "swebench_verified@latest" \
+  --agent-import-path zed_eval.agent:ZedAgent \
+  --ak binary_path=target/eval-cli \
+  --ae EVAL_CLI_TIMEOUT=600 \
+  -m anthropic/claude-sonnet-4-6-latest
+```

crates/eval_cli/build.rs 🔗

@@ -0,0 +1,15 @@
+fn main() {
+    let cargo_toml =
+        std::fs::read_to_string("../zed/Cargo.toml").expect("Failed to read crates/zed/Cargo.toml");
+    let version = cargo_toml
+        .lines()
+        .find(|line| line.starts_with("version = "))
+        .expect("Version not found in crates/zed/Cargo.toml")
+        .split('=')
+        .nth(1)
+        .expect("Invalid version format")
+        .trim()
+        .trim_matches('"');
+    println!("cargo:rerun-if-changed=../zed/Cargo.toml");
+    println!("cargo:rustc-env=ZED_PKG_VERSION={}", version);
+}

crates/eval_cli/script/build-linux 🔗

@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+#
+# Build eval-cli for x86_64 Linux from any host (macOS, Linux, etc.)
+# using Docker. The resulting binary is placed at the path printed on
+# completion (default: target/eval-cli).
+#
+# Usage:
+#   crates/eval_cli/script/build-linux [--output PATH]
+#
+# Examples:
+#   crates/eval_cli/script/build-linux
+#   crates/eval_cli/script/build-linux --output ~/bin/eval-cli
+#
+# Prerequisites: Docker must be installed and running.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+OUTPUT="${REPO_ROOT}/target/eval-cli"
+
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --output)
+            OUTPUT="$2"
+            shift 2
+            ;;
+        *)
+            echo "Unknown option: $1" >&2
+            exit 1
+            ;;
+    esac
+done
+
+cd "$REPO_ROOT"
+
+IMAGE_TAG="eval-cli-builder"
+
+echo "Building eval-cli for x86_64-unknown-linux-gnu..."
+echo "  Repo root: $REPO_ROOT"
+echo "  Output:    $OUTPUT"
+echo ""
+
+docker build \
+    --platform linux/amd64 \
+    -f crates/eval_cli/Dockerfile \
+    -t "$IMAGE_TAG" \
+    .
+
+CONTAINER_ID=$(docker create "$IMAGE_TAG" /eval-cli)
+mkdir -p "$(dirname "$OUTPUT")"
+docker cp "$CONTAINER_ID":/eval-cli "$OUTPUT"
+docker rm "$CONTAINER_ID" > /dev/null
+
+echo ""
+echo "Built successfully: $OUTPUT"
+echo "  $(file "$OUTPUT")"

crates/eval_cli/src/headless.rs 🔗

@@ -0,0 +1,131 @@
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use client::{Client, ProxySettings, UserStore};
+use extension::ExtensionHostProxy;
+use fs::RealFs;
+use gpui::http_client::read_proxy_from_env;
+use gpui::{App, AppContext as _, Entity};
+use gpui_tokio::Tokio;
+use language::LanguageRegistry;
+use language_extension::LspAccess;
+use node_runtime::{NodeBinaryOptions, NodeRuntime};
+use project::project_settings::ProjectSettings;
+use prompt_store::PromptBuilder;
+use release_channel::{AppCommitSha, AppVersion};
+use reqwest_client::ReqwestClient;
+use settings::{Settings, SettingsStore};
+use util::ResultExt as _;
+
+pub struct AgentCliAppState {
+    pub languages: Arc<LanguageRegistry>,
+    pub client: Arc<Client>,
+    pub user_store: Entity<UserStore>,
+    pub fs: Arc<dyn fs::Fs>,
+    pub node_runtime: NodeRuntime,
+}
+
+pub fn init(cx: &mut App) -> Arc<AgentCliAppState> {
+    let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned()));
+
+    let app_version = AppVersion::load(
+        env!("ZED_PKG_VERSION"),
+        option_env!("ZED_BUILD_ID"),
+        app_commit_sha,
+    );
+
+    release_channel::init(app_version.clone(), cx);
+    gpui_tokio::init(cx);
+
+    let settings_store = SettingsStore::new(cx, &settings::default_settings());
+    cx.set_global(settings_store);
+
+    let user_agent = format!(
+        "Zed Agent CLI/{} ({}; {})",
+        app_version,
+        std::env::consts::OS,
+        std::env::consts::ARCH
+    );
+    let proxy_str = ProxySettings::get_global(cx).proxy.to_owned();
+    let proxy_url = proxy_str
+        .as_ref()
+        .and_then(|input| input.parse().ok())
+        .or_else(read_proxy_from_env);
+    let http = {
+        let _guard = Tokio::handle(cx).enter();
+        ReqwestClient::proxy_and_user_agent(proxy_url, &user_agent)
+            .expect("could not start HTTP client")
+    };
+    cx.set_http_client(Arc::new(http));
+
+    let client = Client::production(cx);
+    cx.set_http_client(client.http_client());
+
+    let git_binary_path = None;
+    let fs = Arc::new(RealFs::new(
+        git_binary_path,
+        cx.background_executor().clone(),
+    ));
+
+    let mut languages = LanguageRegistry::new(cx.background_executor().clone());
+    languages.set_language_server_download_dir(paths::languages_dir().clone());
+    let languages = Arc::new(languages);
+
+    let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
+
+    extension::init(cx);
+
+    let (mut node_options_tx, node_options_rx) = watch::channel(None);
+    cx.observe_global::<SettingsStore>(move |cx| {
+        let settings = &ProjectSettings::get_global(cx).node;
+        let options = NodeBinaryOptions {
+            allow_path_lookup: !settings.ignore_system_version,
+            allow_binary_download: true,
+            use_paths: settings.path.as_ref().map(|node_path| {
+                let node_path = PathBuf::from(shellexpand::tilde(node_path).as_ref());
+                let npm_path = settings
+                    .npm_path
+                    .as_ref()
+                    .map(|path| PathBuf::from(shellexpand::tilde(&path).as_ref()));
+                (
+                    node_path.clone(),
+                    npm_path.unwrap_or_else(|| {
+                        let base_path = PathBuf::new();
+                        node_path.parent().unwrap_or(&base_path).join("npm")
+                    }),
+                )
+            }),
+        };
+        node_options_tx.send(Some(options)).log_err();
+    })
+    .detach();
+    let node_runtime = NodeRuntime::new(client.http_client(), None, node_options_rx);
+
+    let extension_host_proxy = ExtensionHostProxy::global(cx);
+    debug_adapter_extension::init(extension_host_proxy.clone(), cx);
+    language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone());
+    language_model::init(user_store.clone(), client.clone(), cx);
+    language_models::init(user_store.clone(), client.clone(), cx);
+    languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx);
+    prompt_store::init(cx);
+    terminal_view::init(cx);
+
+    let stdout_is_a_pty = false;
+    let prompt_builder = PromptBuilder::load(fs.clone(), stdout_is_a_pty, cx);
+    agent_ui::init(
+        fs.clone(),
+        client.clone(),
+        prompt_builder,
+        languages.clone(),
+        true,
+        cx,
+    );
+
+    Arc::new(AgentCliAppState {
+        languages,
+        client,
+        user_store,
+        fs,
+        node_runtime,
+    })
+}

crates/eval_cli/src/main.rs 🔗

@@ -0,0 +1,546 @@
+//! Headless CLI binary for running Zed's agent in evaluation/benchmark environments.
+//!
+//! Designed to work inside containerized environments (like Harbor/termbench) where:
+//! - The repository is already checked out at the working directory
+//! - The model API key is provided via environment variables
+//! - Results are written to an output directory (default: `/logs/agent/`)
+//!
+//! ## Usage
+//!
+//! ```text
+//! eval-cli --workdir /testbed --model anthropic/claude-sonnet-4-6-latest \
+//!          --instruction "Fix the bug described in..." --timeout 600
+//! ```
+//!
+//! ## Output
+//!
+//! Writes to `--output-dir` (default `/logs/agent/`):
+//!   - `result.json`  — structured result with status, timing, and token usage
+//!   - `thread.md`    — full conversation as markdown
+//!   - `thread.json`  — raw thread state as JSON
+//!
+//! ## Exit codes
+//!
+//! | Code | Meaning |
+//! |------|---------|
+//! | 0    | Agent finished |
+//! | 1    | Error (model/auth/runtime failure) |
+//! | 2    | Timeout |
+//! | 3    | Interrupted (SIGTERM/SIGINT) |
+
+mod headless;
+
+use std::path::PathBuf;
+use std::process;
+use std::rc::Rc;
+use std::str::FromStr;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{Duration, Instant};
+
+use acp_thread::AgentConnection as _;
+use agent::{NativeAgent, NativeAgentConnection, Templates, ThreadStore};
+use agent_client_protocol as acp;
+use anyhow::{Context, Result};
+use clap::Parser;
+use feature_flags::FeatureFlagAppExt as _;
+
+use futures::{FutureExt, select_biased};
+use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal};
+use language_model::{LanguageModelRegistry, SelectedModel};
+use project::Project;
+use settings::SettingsStore;
+
+use crate::headless::AgentCliAppState;
+
+#[derive(Parser, Debug)]
+#[command(
+    name = "eval-cli",
+    about = "Run Zed's agent headlessly in evaluation/benchmark environments"
+)]
+struct Args {
+    /// Output current environment variables as JSON to stdout.
+    /// Used internally by Zed's shell environment capture.
+    #[arg(long, hide = true)]
+    printenv: bool,
+
+    /// Path to the repository working directory. Defaults to the current directory.
+    #[arg(long, default_value = ".")]
+    workdir: PathBuf,
+
+    /// Instruction/prompt text. If omitted, read from --instruction-file or stdin.
+    #[arg(long)]
+    instruction: Option<String>,
+
+    /// Language model to use, in `provider/model` format.
+    #[arg(long, default_value = "anthropic/claude-sonnet-4-6-latest")]
+    model: String,
+
+    /// Maximum wall-clock time in seconds for the agent run.
+    #[arg(long)]
+    timeout: Option<u64>,
+
+    /// Directory for output artifacts (result.json, thread.md, thread.json).
+    #[arg(long, default_value = "/logs/agent")]
+    output_dir: PathBuf,
+}
+
+enum AgentOutcome {
+    Completed,
+    Timeout { seconds: u64 },
+    Interrupted,
+}
+
+#[derive(serde::Serialize)]
+struct EvalResult {
+    status: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    error: Option<String>,
+    duration_secs: f64,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    timeout_secs: Option<u64>,
+    model: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    input_tokens: Option<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    output_tokens: Option<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    cache_creation_input_tokens: Option<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    cache_read_input_tokens: Option<u64>,
+}
+
+const EXIT_OK: i32 = 0;
+const EXIT_ERROR: i32 = 1;
+const EXIT_TIMEOUT: i32 = 2;
+const EXIT_INTERRUPTED: i32 = 3;
+
+static TERMINATED: AtomicBool = AtomicBool::new(false);
+
+fn main() {
+    let args = Args::parse();
+
+    if args.printenv {
+        util::shell_env::print_env();
+        return;
+    }
+
+    env_logger::init();
+
+    ctrlc::set_handler(|| {
+        TERMINATED.store(true, Ordering::SeqCst);
+    })
+    .expect("failed to set signal handler");
+
+    let instruction = read_instruction(&args).unwrap_or_else(|e| {
+        eprintln!("Error reading instruction: {e}");
+        process::exit(EXIT_ERROR);
+    });
+
+    let workdir = args.workdir.canonicalize().unwrap_or_else(|e| {
+        eprintln!("Invalid --workdir {:?}: {e}", args.workdir);
+        process::exit(EXIT_ERROR);
+    });
+
+    let output_dir = args.output_dir.clone();
+    if let Err(e) = std::fs::create_dir_all(&output_dir) {
+        eprintln!("Error creating output dir {}: {e}", output_dir.display());
+        process::exit(EXIT_ERROR);
+    }
+
+    let http_client = Arc::new(reqwest_client::ReqwestClient::new());
+    let app = gpui_platform::headless().with_http_client(http_client);
+
+    app.run(move |cx| {
+        let app_state = headless::init(cx);
+        cx.set_staff(true);
+
+        let auth_tasks = LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+            registry
+                .providers()
+                .iter()
+                .map(|p| p.authenticate(cx))
+                .collect::<Vec<_>>()
+        });
+
+        let model_name = args.model.clone();
+        let timeout = args.timeout;
+
+        cx.spawn(async move |cx| {
+            futures::future::join_all(auth_tasks).await;
+
+            let start = Instant::now();
+
+            let (outcome, token_usage) = run_agent(
+                &app_state,
+                &workdir,
+                &instruction,
+                &model_name,
+                timeout,
+                Some(&output_dir),
+                cx,
+            )
+            .await;
+
+            let duration = start.elapsed();
+
+            let (status, error, exit_code) = match &outcome {
+                Ok(AgentOutcome::Completed) => ("completed".to_string(), None, EXIT_OK),
+                Ok(AgentOutcome::Timeout { seconds }) => {
+                    eprintln!("Timeout: agent exceeded {seconds}s time limit");
+                    ("timeout".to_string(), None, EXIT_TIMEOUT)
+                }
+                Ok(AgentOutcome::Interrupted) => {
+                    eprintln!("Interrupted: received SIGTERM, saved partial output");
+                    ("interrupted".to_string(), None, EXIT_INTERRUPTED)
+                }
+                Err(e) => {
+                    eprintln!("Error: {e:#}");
+                    ("error".to_string(), Some(format!("{e:#}")), EXIT_ERROR)
+                }
+            };
+
+            let result = EvalResult {
+                status,
+                error,
+                duration_secs: duration.as_secs_f64(),
+                timeout_secs: timeout,
+                model: model_name.clone(),
+                input_tokens: token_usage.as_ref().map(|u| u.input_tokens),
+                output_tokens: token_usage.as_ref().map(|u| u.output_tokens),
+                cache_creation_input_tokens: token_usage
+                    .as_ref()
+                    .filter(|u| u.cache_creation_input_tokens > 0)
+                    .map(|u| u.cache_creation_input_tokens),
+                cache_read_input_tokens: token_usage
+                    .as_ref()
+                    .filter(|u| u.cache_read_input_tokens > 0)
+                    .map(|u| u.cache_read_input_tokens),
+            };
+
+            match serde_json::to_string_pretty(&result) {
+                Ok(json) => {
+                    if let Err(e) = std::fs::write(output_dir.join("result.json"), &json) {
+                        eprintln!("Error writing result.json: {e:#}");
+                    }
+                    eprintln!("[eval-cli] result: {json}");
+                }
+                Err(e) => eprintln!("Error serializing result: {e:#}"),
+            }
+
+            cx.update(|cx| cx.quit());
+            process::exit(exit_code);
+        })
+        .detach();
+    });
+}
+
+fn read_instruction(args: &Args) -> Result<String> {
+    let text = if let Some(text) = &args.instruction {
+        text.clone()
+    } else {
+        use std::io::Read;
+        let mut buf = String::new();
+        std::io::stdin()
+            .read_to_string(&mut buf)
+            .context("reading instruction from stdin")?;
+        buf
+    };
+    anyhow::ensure!(!text.trim().is_empty(), "instruction is empty");
+    Ok(text)
+}
+
+async fn run_agent(
+    app_state: &Arc<AgentCliAppState>,
+    workdir: &std::path::Path,
+    instruction: &str,
+    model_name: &str,
+    timeout: Option<u64>,
+    output_dir: Option<&std::path::Path>,
+    cx: &mut AsyncApp,
+) -> (Result<AgentOutcome>, Option<language_model::TokenUsage>) {
+    let setup_result: Result<()> = cx.update(|cx| {
+        let selected = SelectedModel::from_str(model_name).map_err(|e| anyhow::anyhow!("{e}"))?;
+        let registry = LanguageModelRegistry::global(cx);
+        let model = registry
+            .read(cx)
+            .available_models(cx)
+            .find(|m| m.id() == selected.model && m.provider_id() == selected.provider)
+            .ok_or_else(|| {
+                let available = registry
+                    .read(cx)
+                    .available_models(cx)
+                    .map(|m| format!("{}/{}", m.provider_id().0, m.id().0))
+                    .collect::<Vec<_>>()
+                    .join(", ");
+                anyhow::anyhow!("Model {model_name} not found. Available: {available}")
+            })?;
+
+        let supports_thinking = model.supports_thinking();
+
+        registry.update(cx, |registry, cx| {
+            registry.set_default_model(
+                Some(language_model::ConfiguredModel {
+                    provider: registry
+                        .provider(&model.provider_id())
+                        .context("Provider not found")?,
+                    model,
+                }),
+                cx,
+            );
+            anyhow::Ok(())
+        })?;
+
+        let (enable_thinking, effort) = if supports_thinking {
+            (true, "\"high\"")
+        } else {
+            (false, "null")
+        };
+        let provider_id = selected.provider.0.to_string();
+        let model_id = selected.model.0.to_string();
+        SettingsStore::update_global(cx, |store, cx| {
+            let settings = format!(
+                r#"{{
+                    "agent": {{
+                        "tool_permissions": {{"default": "allow"}},
+                        "default_model": {{
+                            "provider": "{provider_id}",
+                            "model": "{model_id}",
+                            "enable_thinking": {enable_thinking},
+                            "effort": {effort}
+                        }}
+                    }},
+                    "autosave": "off",
+                    "format_on_save": "off"
+                }}"
+                "#
+            );
+            store.set_user_settings(&settings, cx).ok();
+        });
+
+        anyhow::Ok(())
+    });
+
+    if let Err(e) = setup_result {
+        return (Err(e), None);
+    }
+
+    let project = cx.update(|cx| {
+        Project::local(
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            None,
+            project::LocalProjectFlags {
+                init_worktree_trust: false,
+                ..Default::default()
+            },
+            cx,
+        )
+    });
+
+    let worktree = project.update(cx, |project, cx| project.create_worktree(workdir, true, cx));
+    let worktree = match worktree.await {
+        Ok(w) => w,
+        Err(e) => return (Err(e).context("creating worktree"), None),
+    };
+
+    let scan_result = worktree.update(cx, |tree, _cx| {
+        tree.as_local()
+            .context("expected local worktree")
+            .map(|local| local.scan_complete())
+    });
+    match scan_result {
+        Ok(future) => future.await,
+        Err(e) => return (Err(e), None),
+    };
+
+    let agent = cx.update(|cx| {
+        let thread_store = cx.new(|cx| ThreadStore::new(cx));
+        NativeAgent::new(
+            thread_store,
+            Templates::new(),
+            None,
+            app_state.fs.clone(),
+            cx,
+        )
+    });
+
+    let connection = Rc::new(NativeAgentConnection(agent.clone()));
+    let acp_thread = match cx
+        .update(|cx| connection.clone().new_session(project, workdir, cx))
+        .await
+    {
+        Ok(t) => t,
+        Err(e) => return (Err(e).context("creating ACP session"), None),
+    };
+
+    let _subscription = cx.subscribe(&acp_thread, |acp_thread, event, cx| {
+        log_acp_thread_event(&acp_thread, event, cx);
+    });
+
+    let message = vec![acp::ContentBlock::Text(acp::TextContent::new(
+        instruction.to_string(),
+    ))];
+
+    let send_future = acp_thread.update(cx, |acp_thread: &mut acp_thread::AcpThread, cx| {
+        acp_thread.send(message, cx)
+    });
+
+    let timeout_future = if let Some(timeout_secs) = timeout {
+        futures::future::Either::Left(
+            cx.background_executor()
+                .timer(Duration::from_secs(timeout_secs)),
+        )
+    } else {
+        futures::future::Either::Right(futures::future::pending::<()>())
+    };
+
+    let sigterm_future = {
+        let executor = cx.background_executor().clone();
+        async move {
+            while !TERMINATED.load(Ordering::Relaxed) {
+                executor.timer(Duration::from_millis(100)).await;
+            }
+        }
+    };
+
+    let outcome = select_biased! {
+        result = send_future.fuse() => match result {
+            Ok(Some(response)) => {
+                eprintln!("[eval-cli] stopped: {:?}", response.stop_reason);
+                if response.stop_reason == acp::StopReason::MaxTokens {
+                    Err(anyhow::anyhow!("Model hit maximum token limit"))
+                } else {
+                    Ok(AgentOutcome::Completed)
+                }
+            }
+            Ok(None) => {
+                eprintln!("[eval-cli] completed (no response)");
+                Ok(AgentOutcome::Completed)
+            }
+            Err(e) => Err(e).context("agent run failed"),
+        },
+        _ = sigterm_future.fuse() => {
+            eprintln!("[eval-cli] received SIGTERM, cancelling...");
+            acp_thread.update(cx, |t: &mut acp_thread::AcpThread, cx| t.cancel(cx)).await;
+            Ok(AgentOutcome::Interrupted)
+        },
+        _ = timeout_future.fuse() => {
+            acp_thread.update(cx, |t: &mut acp_thread::AcpThread, cx| t.cancel(cx)).await;
+            Ok(AgentOutcome::Timeout { seconds: timeout.unwrap_or(0) })
+        }
+    };
+
+    let thread = cx.update(|cx| {
+        let session_id = acp_thread.read(cx).session_id().clone();
+        connection.thread(&session_id, cx)
+    });
+
+    let cumulative_usage = if let Some(thread) = &thread {
+        let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx));
+        let db_thread = db_thread.await;
+        let usage = db_thread.cumulative_token_usage;
+        if usage.input_tokens > 0 || usage.output_tokens > 0 {
+            Some(usage)
+        } else {
+            None
+        }
+    } else {
+        None
+    };
+
+    let acp_usage = cx.update(|cx| {
+        acp_thread
+            .read(cx)
+            .token_usage()
+            .map(|usage| language_model::TokenUsage {
+                input_tokens: usage.input_tokens,
+                output_tokens: usage.output_tokens,
+                ..Default::default()
+            })
+    });
+
+    let final_usage = cumulative_usage.or(acp_usage);
+
+    if let (Some(thread), Some(dir)) = (&thread, output_dir) {
+        let markdown = thread.read_with(cx, |thread, _cx| thread.to_markdown());
+        if let Err(e) = std::fs::write(dir.join("thread.md"), markdown) {
+            eprintln!("Error writing thread.md: {e:#}");
+        }
+
+        let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx));
+        let db_thread = db_thread.await;
+        match serde_json::to_string_pretty(&db_thread) {
+            Ok(json) => {
+                if let Err(e) = std::fs::write(dir.join("thread.json"), json) {
+                    eprintln!("Error writing thread.json: {e:#}");
+                }
+            }
+            Err(e) => eprintln!("Error serializing thread.json: {e:#}"),
+        }
+    }
+
+    (outcome, final_usage)
+}
+
+fn log_acp_thread_event(
+    acp_thread: &Entity<acp_thread::AcpThread>,
+    event: &acp_thread::AcpThreadEvent,
+    cx: &mut gpui::App,
+) {
+    match event {
+        acp_thread::AcpThreadEvent::NewEntry => {
+            let entries = acp_thread.read(cx).entries();
+            if let Some(acp_thread::AgentThreadEntry::AssistantMessage(message)) = entries.last() {
+                for chunk in &message.chunks {
+                    if let acp_thread::AssistantMessageChunk::Message { block } = chunk {
+                        if let acp_thread::ContentBlock::Markdown { markdown } = block {
+                            let text = markdown.read(cx).source().to_string();
+                            if !text.is_empty() {
+                                eprint!("{text}");
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        acp_thread::AcpThreadEvent::EntryUpdated(index) => {
+            let entries = acp_thread.read(cx).entries();
+            if let Some(acp_thread::AgentThreadEntry::ToolCall(tool_call)) = entries.get(*index) {
+                if let Some(name) = &tool_call.tool_name {
+                    match &tool_call.status {
+                        acp_thread::ToolCallStatus::Completed => {
+                            eprintln!("[tool] {name} ✓");
+                        }
+                        acp_thread::ToolCallStatus::Failed => {
+                            eprintln!("[tool] {name} ✗");
+                        }
+                        acp_thread::ToolCallStatus::Rejected => {
+                            eprintln!("[tool] {name} rejected");
+                        }
+                        acp_thread::ToolCallStatus::Canceled => {
+                            eprintln!("[tool] {name} canceled");
+                        }
+                        _ => {}
+                    }
+                }
+            }
+        }
+        acp_thread::AcpThreadEvent::Stopped(reason) => {
+            eprintln!("\n[eval-cli] stopped: {reason:?}");
+        }
+        acp_thread::AcpThreadEvent::Error => {
+            eprintln!("[eval-cli] error event");
+        }
+        acp_thread::AcpThreadEvent::Retry(status) => {
+            eprintln!("[eval-cli] retry: {status:?}");
+        }
+        acp_thread::AcpThreadEvent::SubagentSpawned(session_id) => {
+            eprintln!("[eval-cli] subagent spawned: {session_id}");
+        }
+        _ => {}
+    }
+}

crates/eval_cli/zed_eval/agent.py 🔗

@@ -0,0 +1,161 @@
+"""Harbor agent wrapper for Zed's eval-cli binary.
+
+Usage:
+    # Build eval-cli locally first:
+    cargo build --release -p eval_cli
+
+    # Run via Harbor with a local binary:
+    harbor run -d "dataset@version" \
+        --agent-import-path zed_eval.agent:ZedAgent \
+        --ae binary_path=/path/to/target/release/eval-cli \
+        --agent-model anthropic/claude-sonnet-4-6-latest
+
+    # Or with a download URL (for CI):
+    harbor run -d "dataset@version" \
+        --agent-import-path zed_eval.agent:ZedAgent \
+        --ae download_url=https://example.com/eval-cli \
+        --agent-model anthropic/claude-sonnet-4-6-latest
+"""
+
+import json
+import os
+import shlex
+from pathlib import Path
+
+from harbor.agents.installed.base import BaseInstalledAgent, ExecInput
+from harbor.environments.base import BaseEnvironment
+from harbor.models.agent.context import AgentContext
+
+
+class ZedAgent(BaseInstalledAgent):
+    """Runs Zed's headless AI agent (eval-cli) to solve tasks.
+
+    The eval-cli binary boots a headless GPUI application and uses the same
+    NativeAgent + AcpThread pipeline as the production Zed editor, driving
+    the full agentic loop (tool calls, subagents, retries) without a GUI.
+    """
+
+    def __init__(
+        self,
+        logs_dir: Path,
+        binary_path: str | None = None,
+        download_url: str | None = None,
+        *args,
+        **kwargs,
+    ):
+        super().__init__(logs_dir, *args, **kwargs)
+        self._binary_path = binary_path
+        self._download_url = download_url or os.environ.get("EVAL_CLI_DOWNLOAD_URL")
+
+    @staticmethod
+    def name() -> str:
+        return "zed"
+
+    @property
+    def _install_agent_template_path(self) -> Path:
+        return Path(__file__).parent / "install.sh.j2"
+
+    async def setup(self, environment: BaseEnvironment) -> None:
+        await environment.exec(command="mkdir -p /installed-agent")
+
+        if self._binary_path:
+            binary = Path(self._binary_path)
+            if not binary.exists():
+                raise FileNotFoundError(
+                    f"eval-cli binary not found at {binary}. "
+                    "Build it with: cargo build --release -p eval_cli"
+                )
+            await environment.upload_file(
+                source_path=binary,
+                target_path="/usr/local/bin/eval-cli",
+            )
+            await environment.exec(command="chmod +x /usr/local/bin/eval-cli")
+
+        await super().setup(environment)
+
+    @property
+    def _template_variables(self) -> dict[str, str]:
+        variables = super()._template_variables
+        if self._binary_path:
+            variables["binary_uploaded"] = "true"
+        if self._download_url:
+            variables["download_url"] = self._download_url
+        return variables
+
+    def populate_context_post_run(self, context: AgentContext) -> None:
+        result_data = None
+        for json_file in self.logs_dir.rglob("result.json"):
+            try:
+                result_data = json.loads(json_file.read_text())
+                break
+            except (json.JSONDecodeError, OSError):
+                continue
+
+        if result_data is None:
+            self.logger.warning("Could not find or parse result.json from eval-cli")
+            return
+
+        if result_data.get("input_tokens") is not None:
+            context.n_input_tokens = result_data["input_tokens"]
+        if result_data.get("output_tokens") is not None:
+            context.n_output_tokens = result_data["output_tokens"]
+        if result_data.get("cache_read_input_tokens") is not None:
+            context.n_cache_tokens = result_data["cache_read_input_tokens"]
+
+        context.metadata = {
+            "status": result_data.get("status"),
+            "duration_secs": result_data.get("duration_secs"),
+            "model": result_data.get("model"),
+        }
+
+    def _get_api_env(self) -> dict[str, str]:
+        env: dict[str, str] = {}
+        if not self.model_name or "/" not in self.model_name:
+            return env
+
+        provider = self.model_name.split("/", 1)[0]
+        provider_env_map = {
+            "anthropic": "ANTHROPIC_API_KEY",
+            "openai": "OPENAI_API_KEY",
+            "google": "GEMINI_API_KEY",
+            "gemini": "GEMINI_API_KEY",
+            "deepseek": "DEEPSEEK_API_KEY",
+            "mistral": "MISTRAL_API_KEY",
+        }
+
+        env_var = provider_env_map.get(provider)
+        if env_var:
+            api_key = os.environ.get(env_var, "")
+            if api_key:
+                env[env_var] = api_key
+
+        return env
+
+    def create_run_agent_commands(self, instruction: str) -> list[ExecInput]:
+        escaped_instruction = shlex.quote(instruction)
+        env = self._get_api_env()
+
+        parts = ["eval-cli", "--workdir /testbed", "--output-dir /logs/agent"]
+
+        if self.model_name:
+            parts.append(f"--model {self.model_name}")
+
+        timeout = self._extra_env.get("EVAL_CLI_TIMEOUT")
+        if timeout:
+            parts.append(f"--timeout {timeout}")
+
+        parts.append(f"--instruction {escaped_instruction}")
+
+        eval_cli_command = " ".join(parts) + " 2>&1 | stdbuf -oL tee /logs/agent/eval-cli.txt"
+
+        patch_command = (
+            "cd /testbed && "
+            "git add -A && "
+            "git diff --cached HEAD > /logs/agent/patch.diff && "
+            "echo \"Patch size: $(wc -c < /logs/agent/patch.diff) bytes\""
+        )
+
+        return [
+            ExecInput(command=eval_cli_command, env=env),
+            ExecInput(command=patch_command),
+        ]

crates/eval_cli/zed_eval/install.sh.j2 🔗

@@ -0,0 +1,49 @@
+#!/bin/bash
+set -euo pipefail
+
+# Install runtime dependencies needed by the eval-cli binary (dynamically linked
+# against glibc + these shared libraries from its GPUI/terminal/language stacks).
+apt-get update
+apt-get install -y --no-install-recommends \
+    ca-certificates \
+    curl \
+    git \
+    libasound2 \
+    libfontconfig1 \
+    libglib2.0-0 \
+    libsqlite3-0 \
+    libssl3 \
+    libwayland-client0 \
+    libx11-xcb1 \
+    libxkbcommon-x11-0 \
+    libzstd1
+
+# Install Node.js 22 LTS (needed by language servers like basedpyright).
+curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
+apt-get install -y --no-install-recommends nodejs
+
+# Install uv (needed for running Python tests in SWE-bench tasks).
+curl -LsSf https://astral.sh/uv/install.sh | sh
+. "$HOME/.local/bin/env"
+ln -sf "$HOME/.local/bin/uv" /usr/local/bin/uv
+ln -sf "$HOME/.local/bin/uvx" /usr/local/bin/uvx
+
+{% if binary_uploaded is defined %}
+# Binary was uploaded directly via setup() — just verify it works.
+eval-cli --help
+{% elif download_url is defined %}
+curl -fsSL "{{ download_url }}" -o /usr/local/bin/eval-cli
+chmod +x /usr/local/bin/eval-cli
+eval-cli --help
+{% else %}
+echo "ERROR: No eval-cli binary provided."
+echo ""
+echo "Either pass binary_path= to upload a local build:"
+echo "  --ae binary_path=/path/to/target/release/eval-cli"
+echo ""
+echo "Or set download_url= / EVAL_CLI_DOWNLOAD_URL:"
+echo "  --ae download_url=https://example.com/eval-cli"
+exit 1
+{% endif %}
+
+echo "INSTALL_SUCCESS"

crates/eval_cli/zed_eval/pyproject.toml 🔗

@@ -0,0 +1,10 @@
+[project]
+name = "zed-eval"
+version = "0.1.0"
+description = "Harbor agent wrapper for Zed's eval-cli"
+requires-python = ">=3.12"
+dependencies = ["harbor"]
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"

crates/extension/src/extension.rs 🔗

@@ -80,6 +80,18 @@ pub trait Extension: Send + Sync + 'static {
         worktree: Arc<dyn WorktreeDelegate>,
     ) -> Result<Option<String>>;
 
+    async fn language_server_initialization_options_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>>;
+
+    async fn language_server_workspace_configuration_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>>;
+
     async fn language_server_additional_initialization_options(
         &self,
         language_server_id: LanguageServerName,

crates/extension/src/extension_builder.rs 🔗

@@ -7,6 +7,7 @@ use anyhow::{Context as _, Result, bail};
 use futures::{StreamExt, io};
 use heck::ToSnakeCase;
 use http_client::{self, AsyncBody, HttpClient};
+use language::LanguageConfig;
 use serde::Deserialize;
 use std::{
     env, fs, mem,
@@ -583,7 +584,7 @@ async fn populate_defaults(
 
         while let Some(language_dir) = language_dir_entries.next().await {
             let language_dir = language_dir?;
-            let config_path = language_dir.join("config.toml");
+            let config_path = language_dir.join(LanguageConfig::FILE_NAME);
             if fs.is_file(config_path.as_path()).await {
                 let relative_language_dir =
                     language_dir.strip_prefix(extension_path)?.to_path_buf();

crates/extension_api/src/extension_api.rs 🔗

@@ -100,6 +100,28 @@ pub trait Extension: Send + Sync {
         Ok(None)
     }
 
+    /// Returns the JSON schema for the initialization options.
+    ///
+    /// The schema must conform to the JSON Schema speification.
+    fn language_server_initialization_options_schema(
+        &mut self,
+        _language_server_id: &LanguageServerId,
+        _worktree: &Worktree,
+    ) -> Option<serde_json::Value> {
+        None
+    }
+
+    /// Returns the JSON schema for the workspace configuration.
+    ///
+    /// The schema must conform to the JSON Schema specification.
+    fn language_server_workspace_configuration_schema(
+        &mut self,
+        _language_server_id: &LanguageServerId,
+        _worktree: &Worktree,
+    ) -> Option<serde_json::Value> {
+        None
+    }
+
     /// Returns the initialization options to pass to the other language server.
     fn language_server_additional_initialization_options(
         &mut self,
@@ -370,6 +392,26 @@ impl wit::Guest for Component {
             .and_then(|value| serde_json::to_string(&value).ok()))
     }
 
+    fn language_server_initialization_options_schema(
+        language_server_id: String,
+        worktree: &Worktree,
+    ) -> Option<String> {
+        let language_server_id = LanguageServerId(language_server_id);
+        extension()
+            .language_server_initialization_options_schema(&language_server_id, worktree)
+            .and_then(|value| serde_json::to_string(&value).ok())
+    }
+
+    fn language_server_workspace_configuration_schema(
+        language_server_id: String,
+        worktree: &Worktree,
+    ) -> Option<String> {
+        let language_server_id = LanguageServerId(language_server_id);
+        extension()
+            .language_server_workspace_configuration_schema(&language_server_id, worktree)
+            .and_then(|value| serde_json::to_string(&value).ok())
+    }
+
     fn language_server_additional_initialization_options(
         language_server_id: String,
         target_language_server_id: String,

crates/extension_api/wit/since_v0.8.0/extension.wit 🔗

@@ -101,6 +101,16 @@ world extension {
     /// Returns the workspace configuration options to pass to the language server.
     export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
 
+    /// Returns the JSON schema for the initialization options.
+    ///
+    /// The schema is represented as a JSON string conforming to the JSON Schema specification.
+    export language-server-initialization-options-schema: func(language-server-id: string, worktree: borrow<worktree>) -> option<string>;
+
+    /// Returns the JSON schema for the workspace configuration.
+    ///
+    /// The schema is represented as a JSON string conforming to the JSON Schema specification.
+    export language-server-workspace-configuration-schema: func(language-server-id: string, worktree: borrow<worktree>) -> option<string>;
+
     /// Returns the initialization options to pass to the other language server.
     export language-server-additional-initialization-options: func(language-server-id: string, target-language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
 

crates/extension_cli/Cargo.toml 🔗

@@ -26,7 +26,9 @@ reqwest_client.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 serde_json_lenient.workspace = true
+settings_content.workspace = true
 snippet_provider.workspace = true
+task.workspace = true
 theme.workspace = true
 tokio = { workspace = true, features = ["full"] }
 toml.workspace = true

crates/extension_cli/src/main.rs 🔗

@@ -11,8 +11,10 @@ use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
 use extension::{ExtensionManifest, ExtensionSnippets};
 use language::LanguageConfig;
 use reqwest_client::ReqwestClient;
+use settings_content::SemanticTokenRules;
 use snippet_provider::file_to_snippets;
 use snippet_provider::format::VsSnippetsFile;
+use task::TaskTemplates;
 use tokio::process::Command;
 use tree_sitter::{Language, Query, WasmStore};
 
@@ -323,9 +325,8 @@ fn test_languages(
 ) -> Result<()> {
     for relative_language_dir in &manifest.languages {
         let language_dir = extension_path.join(relative_language_dir);
-        let config_path = language_dir.join("config.toml");
-        let config_content = fs::read_to_string(&config_path)?;
-        let config: LanguageConfig = toml::from_str(&config_content)?;
+        let config_path = language_dir.join(LanguageConfig::FILE_NAME);
+        let config = LanguageConfig::load(&config_path)?;
         let grammar = if let Some(name) = &config.grammar {
             Some(
                 grammars
@@ -339,18 +340,48 @@ fn test_languages(
         let query_entries = fs::read_dir(&language_dir)?;
         for entry in query_entries {
             let entry = entry?;
-            let query_path = entry.path();
-            if query_path.extension() == Some("scm".as_ref()) {
-                let grammar = grammar.with_context(|| {
-                    format! {
-                        "language {} provides query {} but no grammar",
-                        config.name,
-                        query_path.display()
-                    }
-                })?;
-
-                let query_source = fs::read_to_string(&query_path)?;
-                let _query = Query::new(grammar, &query_source)?;
+            let file_path = entry.path();
+
+            let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
+                continue;
+            };
+
+            match file_name {
+                LanguageConfig::FILE_NAME => {
+                    // Loaded above
+                }
+                SemanticTokenRules::FILE_NAME => {
+                    let _token_rules = SemanticTokenRules::load(&file_path)?;
+                }
+                TaskTemplates::FILE_NAME => {
+                    let task_file_content = std::fs::read(&file_path).with_context(|| {
+                        anyhow!(
+                            "Failed to read tasks file at {path}",
+                            path = file_path.display()
+                        )
+                    })?;
+                    let _task_templates =
+                        serde_json_lenient::from_slice::<TaskTemplates>(&task_file_content)
+                            .with_context(|| {
+                                anyhow!(
+                                    "Failed to parse tasks file at {path}",
+                                    path = file_path.display()
+                                )
+                            })?;
+                }
+                _ if file_name.ends_with(".scm") => {
+                    let grammar = grammar.with_context(|| {
+                        format! {
+                            "language {} provides query {} but no grammar",
+                            config.name,
+                            file_path.display()
+                        }
+                    })?;
+
+                    let query_source = fs::read_to_string(&file_path)?;
+                    let _query = Query::new(grammar, &query_source)?;
+                }
+                _ => {}
             }
         }
 

crates/extension_host/Cargo.toml 🔗

@@ -65,7 +65,7 @@ language = { workspace = true, features = ["test-support"] }
 language_extension.workspace = true
 parking_lot.workspace = true
 project = { workspace = true, features = ["test-support"] }
-rand.workspace = true
+
 reqwest_client.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 theme_extension.workspace = true

crates/extension_host/src/extension_host.rs 🔗

@@ -55,6 +55,7 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
+use task::TaskTemplates;
 use url::Url;
 use util::{ResultExt, paths::RemotePathBuf};
 use wasm_host::{
@@ -1285,19 +1286,11 @@ impl ExtensionStore {
             ]);
 
             // Load semantic token rules if present in the language directory.
-            let rules_path = language_path.join("semantic_token_rules.json");
-            if let Ok(rules_json) = std::fs::read_to_string(&rules_path) {
-                match serde_json_lenient::from_str::<SemanticTokenRules>(&rules_json) {
-                    Ok(rules) => {
-                        semantic_token_rules_to_add.push((language_name.clone(), rules));
-                    }
-                    Err(err) => {
-                        log::error!(
-                            "Failed to parse semantic token rules from {}: {err:#}",
-                            rules_path.display()
-                        );
-                    }
-                }
+            let rules_path = language_path.join(SemanticTokenRules::FILE_NAME);
+            if std::fs::exists(&rules_path).is_ok_and(|exists| exists)
+                && let Some(rules) = SemanticTokenRules::load(&rules_path).log_err()
+            {
+                semantic_token_rules_to_add.push((language_name.clone(), rules));
             }
 
             self.proxy.register_language(
@@ -1306,11 +1299,11 @@ impl ExtensionStore {
                 language.matcher.clone(),
                 language.hidden,
                 Arc::new(move || {
-                    let config = std::fs::read_to_string(language_path.join("config.toml"))?;
-                    let config: LanguageConfig = ::toml::from_str(&config)?;
+                    let config =
+                        LanguageConfig::load(language_path.join(LanguageConfig::FILE_NAME))?;
                     let queries = load_plugin_queries(&language_path);
                     let context_provider =
-                        std::fs::read_to_string(language_path.join("tasks.json"))
+                        std::fs::read_to_string(language_path.join(TaskTemplates::FILE_NAME))
                             .ok()
                             .and_then(|contents| {
                                 let definitions =
@@ -1580,7 +1573,7 @@ impl ExtensionStore {
                 if !fs_metadata.is_dir {
                     continue;
                 }
-                let language_config_path = language_path.join("config.toml");
+                let language_config_path = language_path.join(LanguageConfig::FILE_NAME);
                 let config = fs.load(&language_config_path).await.with_context(|| {
                     format!("loading language config from {language_config_path:?}")
                 })?;
@@ -1703,7 +1696,7 @@ impl ExtensionStore {
         cx.background_spawn(async move {
             const EXTENSION_TOML: &str = "extension.toml";
             const EXTENSION_WASM: &str = "extension.wasm";
-            const CONFIG_TOML: &str = "config.toml";
+            const CONFIG_TOML: &str = LanguageConfig::FILE_NAME;
 
             if is_dev {
                 let manifest_toml = toml::to_string(&loaded_extension.manifest)?;

crates/extension_host/src/headless_host.rs 🔗

@@ -138,7 +138,9 @@ impl HeadlessExtensionStore {
 
         for language_path in &manifest.languages {
             let language_path = extension_dir.join(language_path);
-            let config = fs.load(&language_path.join("config.toml")).await?;
+            let config = fs
+                .load(&language_path.join(LanguageConfig::FILE_NAME))
+                .await?;
             let mut config = ::toml::from_str::<LanguageConfig>(&config)?;
 
             this.update(cx, |this, _cx| {

crates/extension_host/src/wasm_host.rs 🔗

@@ -159,6 +159,48 @@ impl extension::Extension for WasmExtension {
         .await?
     }
 
+    async fn language_server_initialization_options_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>> {
+        self.call(|extension, store| {
+            async move {
+                let resource = store.data_mut().table().push(worktree)?;
+                extension
+                    .call_language_server_initialization_options_schema(
+                        store,
+                        &language_server_id,
+                        resource,
+                    )
+                    .await
+            }
+            .boxed()
+        })
+        .await?
+    }
+
+    async fn language_server_workspace_configuration_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>> {
+        self.call(|extension, store| {
+            async move {
+                let resource = store.data_mut().table().push(worktree)?;
+                extension
+                    .call_language_server_workspace_configuration_schema(
+                        store,
+                        &language_server_id,
+                        resource,
+                    )
+                    .await
+            }
+            .boxed()
+        })
+        .await?
+    }
+
     async fn language_server_additional_initialization_options(
         &self,
         language_server_id: LanguageServerName,

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

@@ -465,6 +465,60 @@ impl Extension {
         }
     }
 
+    pub async fn call_language_server_initialization_options_schema(
+        &self,
+        store: &mut Store<WasmState>,
+        language_server_id: &LanguageServerName,
+        resource: Resource<Arc<dyn WorktreeDelegate>>,
+    ) -> Result<Option<String>> {
+        match self {
+            Extension::V0_8_0(ext) => {
+                ext.call_language_server_initialization_options_schema(
+                    store,
+                    &language_server_id.0,
+                    resource,
+                )
+                .await
+            }
+            Extension::V0_6_0(_)
+            | Extension::V0_5_0(_)
+            | Extension::V0_4_0(_)
+            | Extension::V0_3_0(_)
+            | Extension::V0_2_0(_)
+            | Extension::V0_1_0(_)
+            | Extension::V0_0_6(_)
+            | Extension::V0_0_4(_)
+            | Extension::V0_0_1(_) => Ok(None),
+        }
+    }
+
+    pub async fn call_language_server_workspace_configuration_schema(
+        &self,
+        store: &mut Store<WasmState>,
+        language_server_id: &LanguageServerName,
+        resource: Resource<Arc<dyn WorktreeDelegate>>,
+    ) -> Result<Option<String>> {
+        match self {
+            Extension::V0_8_0(ext) => {
+                ext.call_language_server_workspace_configuration_schema(
+                    store,
+                    &language_server_id.0,
+                    resource,
+                )
+                .await
+            }
+            Extension::V0_6_0(_)
+            | Extension::V0_5_0(_)
+            | Extension::V0_4_0(_)
+            | Extension::V0_3_0(_)
+            | Extension::V0_2_0(_)
+            | Extension::V0_1_0(_)
+            | Extension::V0_0_6(_)
+            | Extension::V0_0_4(_)
+            | Extension::V0_0_1(_) => Ok(None),
+        }
+    }
+
     pub async fn call_language_server_additional_initialization_options(
         &self,
         store: &mut Store<WasmState>,

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -870,9 +870,12 @@ impl ExtensionsPage {
             )
             .child(
                 h_flex()
+                    .min_w_0()
+                    .w_full()
                     .justify_between()
                     .child(
                         h_flex()
+                            .min_w_0()
                             .gap_1()
                             .child(
                                 Icon::new(IconName::Person)
@@ -889,6 +892,7 @@ impl ExtensionsPage {
                     .child(
                         h_flex()
                             .gap_1()
+                            .flex_shrink_0()
                             .child({
                                 let repo_url_for_tooltip = repository_url.clone();
 
@@ -1052,10 +1056,11 @@ impl ExtensionsPage {
                     "Install",
                 )
                 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                .icon(IconName::Download)
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted)
-                .icon_position(IconPosition::Start)
+                .start_icon(
+                    Icon::new(IconName::Download)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
                 .on_click({
                     let extension_id = extension.id.clone();
                     move |_, _, cx| {
@@ -1074,10 +1079,11 @@ impl ExtensionsPage {
                     "Install",
                 )
                 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                .icon(IconName::Download)
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted)
-                .icon_position(IconPosition::Start)
+                .start_icon(
+                    Icon::new(IconName::Download)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
                 .disabled(true),
                 configure: None,
                 upgrade: None,
@@ -1475,10 +1481,11 @@ impl ExtensionsPage {
                 }
             });
         let open_registry_button = Button::new("open_registry", "Learn More")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Small)
-            .icon_position(IconPosition::End)
-            .icon_color(Color::Muted)
+            .end_icon(
+                Icon::new(IconName::ArrowUpRight)
+                    .size(IconSize::Small)
+                    .color(Color::Muted),
+            )
             .on_click({
                 move |_event, _window, cx| {
                     telemetry::event!(
@@ -1516,9 +1523,7 @@ impl ExtensionsPage {
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let docs_url_button = Button::new("open_docs", "View Documentation")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Small)
-            .icon_position(IconPosition::End)
+            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small))
             .on_click({
                 move |_event, _window, cx| {
                     telemetry::event!(

crates/feature_flags/src/feature_flags.rs 🔗

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

crates/feedback/Cargo.toml 🔗

@@ -22,5 +22,3 @@ util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 
-[dev-dependencies]
-editor = { workspace = true, features = ["test-support"] }

crates/file_finder/Cargo.toml 🔗

@@ -14,6 +14,8 @@ doctest = false
 
 [dependencies]
 anyhow.workspace = true
+channel.workspace = true
+client.workspace = true
 collections.workspace = true
 editor.workspace = true
 file_icons.workspace = true
@@ -38,10 +40,11 @@ project_panel.workspace = true
 ctor.workspace = true
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
-language = { workspace = true, features = ["test-support"] }
+
 picker = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
 zlog.workspace = true
+remote_connection = { workspace = true, features = ["test-support"] }

crates/file_finder/src/file_finder.rs 🔗

@@ -4,10 +4,12 @@ mod file_finder_tests;
 use futures::future::join_all;
 pub use open_path_prompt::OpenPathDelegate;
 
+use channel::ChannelStore;
+use client::ChannelId;
 use collections::HashMap;
 use editor::Editor;
 use file_icons::FileIcons;
-use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
+use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate};
 use gpui::{
     Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
     KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
@@ -45,8 +47,8 @@ use util::{
     rel_path::RelPath,
 };
 use workspace::{
-    ModalView, OpenOptions, OpenVisible, SplitDirection, Workspace, item::PreviewTabsSettings,
-    notifications::NotifyResultExt, pane,
+    ModalView, OpenChannelNotesById, OpenOptions, OpenVisible, SplitDirection, Workspace,
+    item::PreviewTabsSettings, notifications::NotifyResultExt, pane,
 };
 use zed_actions::search::ToggleIncludeIgnored;
 
@@ -321,7 +323,7 @@ impl FileFinder {
             if let Some(workspace) = delegate.workspace.upgrade()
                 && let Some(m) = delegate.matches.get(delegate.selected_index())
             {
-                let path = match &m {
+                let path = match m {
                     Match::History { path, .. } => {
                         let worktree_id = path.project.worktree_id;
                         ProjectPath {
@@ -334,6 +336,7 @@ impl FileFinder {
                         path: m.0.path.clone(),
                     },
                     Match::CreateNew(p) => p.clone(),
+                    Match::Channel { .. } => return,
                 };
                 let open_task = workspace.update(cx, move |workspace, cx| {
                     workspace.split_path_preview(path, false, Some(split_direction), window, cx)
@@ -392,6 +395,7 @@ pub struct FileFinderDelegate {
     file_finder: WeakEntity<FileFinder>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
+    channel_store: Option<Entity<ChannelStore>>,
     search_count: usize,
     latest_search_id: usize,
     latest_search_did_cancel: bool,
@@ -450,13 +454,18 @@ struct Matches {
     matches: Vec<Match>,
 }
 
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
+#[derive(Debug, Clone)]
 enum Match {
     History {
         path: FoundPath,
         panel_match: Option<ProjectPanelOrdMatch>,
     },
     Search(ProjectPanelOrdMatch),
+    Channel {
+        channel_id: ChannelId,
+        channel_name: SharedString,
+        string_match: StringMatch,
+    },
     CreateNew(ProjectPath),
 }
 
@@ -465,7 +474,7 @@ impl Match {
         match self {
             Match::History { path, .. } => Some(&path.project.path),
             Match::Search(panel_match) => Some(&panel_match.0.path),
-            Match::CreateNew(_) => None,
+            Match::Channel { .. } | Match::CreateNew(_) => None,
         }
     }
 
@@ -479,7 +488,7 @@ impl Match {
                     .read(cx)
                     .absolutize(&path_match.path),
             ),
-            Match::CreateNew(_) => None,
+            Match::Channel { .. } | Match::CreateNew(_) => None,
         }
     }
 
@@ -487,7 +496,7 @@ impl Match {
         match self {
             Match::History { panel_match, .. } => panel_match.as_ref(),
             Match::Search(panel_match) => Some(panel_match),
-            Match::CreateNew(_) => None,
+            Match::Channel { .. } | Match::CreateNew(_) => None,
         }
     }
 }
@@ -628,7 +637,6 @@ impl Matches {
             (_, Match::CreateNew(_)) => return cmp::Ordering::Greater,
             _ => {}
         }
-        debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
 
         match (&a, &b) {
             // bubble currently opened files to the top
@@ -651,32 +659,35 @@ impl Matches {
             }
         }
 
-        let a_panel_match = match a.panel_match() {
-            Some(pm) => pm,
-            None => {
-                return if b.panel_match().is_some() {
-                    cmp::Ordering::Less
-                } else {
-                    cmp::Ordering::Equal
-                };
+        // For file-vs-file matches, use the existing detailed comparison.
+        if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) {
+            let a_in_filename = Self::is_filename_match(a_panel);
+            let b_in_filename = Self::is_filename_match(b_panel);
+
+            match (a_in_filename, b_in_filename) {
+                (true, false) => return cmp::Ordering::Greater,
+                (false, true) => return cmp::Ordering::Less,
+                _ => {}
             }
-        };
 
-        let b_panel_match = match b.panel_match() {
-            Some(pm) => pm,
-            None => return cmp::Ordering::Greater,
-        };
+            return a_panel.cmp(b_panel);
+        }
 
-        let a_in_filename = Self::is_filename_match(a_panel_match);
-        let b_in_filename = Self::is_filename_match(b_panel_match);
+        let a_score = Self::match_score(a);
+        let b_score = Self::match_score(b);
+        // When at least one side is a channel, compare by raw score.
+        a_score
+            .partial_cmp(&b_score)
+            .unwrap_or(cmp::Ordering::Equal)
+    }
 
-        match (a_in_filename, b_in_filename) {
-            (true, false) => return cmp::Ordering::Greater,
-            (false, true) => return cmp::Ordering::Less,
-            _ => {} // Both are filename matches or both are path matches
+    fn match_score(m: &Match) -> f64 {
+        match m {
+            Match::History { panel_match, .. } => panel_match.as_ref().map_or(0.0, |pm| pm.0.score),
+            Match::Search(pm) => pm.0.score,
+            Match::Channel { string_match, .. } => string_match.score,
+            Match::CreateNew(_) => 0.0,
         }
-
-        a_panel_match.cmp(b_panel_match)
     }
 
     /// Determines if the match occurred within the filename rather than in the path
@@ -833,10 +844,16 @@ impl FileFinderDelegate {
         cx: &mut Context<FileFinder>,
     ) -> Self {
         Self::subscribe_to_updates(&project, window, cx);
+        let channel_store = if FileFinderSettings::get_global(cx).include_channels {
+            ChannelStore::try_global(cx)
+        } else {
+            None
+        };
         Self {
             file_finder,
             workspace,
             project,
+            channel_store,
             search_count: 0,
             latest_search_id: 0,
             latest_search_did_cancel: false,
@@ -971,6 +988,68 @@ impl FileFinderDelegate {
                 path_style,
             );
 
+            // Add channel matches
+            if let Some(channel_store) = &self.channel_store {
+                let channel_store = channel_store.read(cx);
+                let channels: Vec<_> = channel_store.channels().cloned().collect();
+                if !channels.is_empty() {
+                    let candidates = channels
+                        .iter()
+                        .enumerate()
+                        .map(|(id, channel)| StringMatchCandidate::new(id, &channel.name));
+                    let channel_query = query.path_query();
+                    let query_lower = channel_query.to_lowercase();
+                    let mut channel_matches = Vec::new();
+                    for candidate in candidates {
+                        let channel_name = candidate.string;
+                        let name_lower = channel_name.to_lowercase();
+
+                        let mut positions = Vec::new();
+                        let mut query_idx = 0;
+                        for (name_idx, name_char) in name_lower.char_indices() {
+                            if query_idx < query_lower.len() {
+                                let query_char =
+                                    query_lower[query_idx..].chars().next().unwrap_or_default();
+                                if name_char == query_char {
+                                    positions.push(name_idx);
+                                    query_idx += query_char.len_utf8();
+                                }
+                            }
+                        }
+
+                        if query_idx == query_lower.len() {
+                            let channel = &channels[candidate.id];
+                            let score = if name_lower == query_lower {
+                                1.0
+                            } else if name_lower.starts_with(&query_lower) {
+                                0.8
+                            } else {
+                                0.5 * (query_lower.len() as f64 / name_lower.len() as f64)
+                            };
+                            channel_matches.push(Match::Channel {
+                                channel_id: channel.id,
+                                channel_name: channel.name.clone(),
+                                string_match: StringMatch {
+                                    candidate_id: candidate.id,
+                                    score,
+                                    positions,
+                                    string: channel_name,
+                                },
+                            });
+                        }
+                    }
+                    for channel_match in channel_matches {
+                        match self
+                            .matches
+                            .position(&channel_match, self.currently_opened_path.as_ref())
+                        {
+                            Ok(_duplicate) => {}
+                            Err(ix) => self.matches.matches.insert(ix, channel_match),
+                        }
+                    }
+                }
+            }
+
             let query_path = query.raw_query.as_str();
             if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) {
                 let available_worktree = self
@@ -1095,6 +1174,16 @@ impl FileFinderDelegate {
                     }
                 }
                 Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style),
+                Match::Channel {
+                    channel_name,
+                    string_match,
+                    ..
+                } => (
+                    channel_name.to_string(),
+                    string_match.positions.clone(),
+                    "Channel Notes".to_string(),
+                    vec![],
+                ),
                 Match::CreateNew(project_path) => (
                     format!("Create file: {}", project_path.path.display(path_style)),
                     vec![],
@@ -1479,6 +1568,16 @@ impl PickerDelegate for FileFinderDelegate {
         if let Some(m) = self.matches.get(self.selected_index())
             && let Some(workspace) = self.workspace.upgrade()
         {
+            // Channel matches are handled separately since they dispatch an action
+            // rather than directly opening a file path.
+            if let Match::Channel { channel_id, .. } = m {
+                let channel_id = channel_id.0;
+                let finder = self.file_finder.clone();
+                window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx);
+                finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err();
+                return;
+            }
+
             let open_task = workspace.update(cx, |workspace, cx| {
                 let split_or_open =
                     |workspace: &mut Workspace,
@@ -1571,6 +1670,7 @@ impl PickerDelegate for FileFinderDelegate {
                         window,
                         cx,
                     ),
+                    Match::Channel { .. } => unreachable!("handled above"),
                 }
             });
 
@@ -1627,7 +1727,7 @@ impl PickerDelegate for FileFinderDelegate {
 
         let path_match = self.matches.get(ix)?;
 
-        let history_icon = match &path_match {
+        let end_icon = match path_match {
             Match::History { .. } => Icon::new(IconName::HistoryRerun)
                 .color(Color::Muted)
                 .size(IconSize::Small)
@@ -1636,6 +1736,10 @@ impl PickerDelegate for FileFinderDelegate {
                 .flex_none()
                 .size(IconSize::Small.rems())
                 .into_any_element(),
+            Match::Channel { .. } => v_flex()
+                .flex_none()
+                .size(IconSize::Small.rems())
+                .into_any_element(),
             Match::CreateNew(_) => Icon::new(IconName::Plus)
                 .color(Color::Muted)
                 .size(IconSize::Small)
@@ -1643,21 +1747,24 @@ impl PickerDelegate for FileFinderDelegate {
         };
         let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
 
-        let file_icon = maybe!({
-            if !settings.file_icons {
-                return None;
-            }
-            let abs_path = path_match.abs_path(&self.project, cx)?;
-            let file_name = abs_path.file_name()?;
-            let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
-            Some(Icon::from_path(icon).color(Color::Muted))
-        });
+        let file_icon = match path_match {
+            Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
+            _ => maybe!({
+                if !settings.file_icons {
+                    return None;
+                }
+                let abs_path = path_match.abs_path(&self.project, cx)?;
+                let file_name = abs_path.file_name()?;
+                let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
+                Some(Icon::from_path(icon).color(Color::Muted))
+            }),
+        };
 
         Some(
             ListItem::new(ix)
                 .spacing(ListItemSpacing::Sparse)
                 .start_slot::<Icon>(file_icon)
-                .end_slot::<AnyElement>(history_icon)
+                .end_slot::<AnyElement>(end_icon)
                 .inset(true)
                 .toggle_state(selected)
                 .child(

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -3709,7 +3709,7 @@ impl SearchEntries {
 fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
     let mut search_entries = SearchEntries::default();
     for m in &picker.delegate.matches.matches {
-        match &m {
+        match m {
             Match::History {
                 path: history_path,
                 panel_match: path_match,
@@ -3734,6 +3734,7 @@ fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries
                 search_entries.search_matches.push(path_match.0.clone());
             }
             Match::CreateNew(_) => {}
+            Match::Channel { .. } => {}
         }
     }
     search_entries
@@ -3768,6 +3769,7 @@ fn assert_match_at_position(
         Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()),
         Match::Search(path_match) => path_match.0.path.file_name(),
         Match::CreateNew(project_path) => project_path.path.file_name(),
+        Match::Channel { channel_name, .. } => Some(channel_name.as_str()),
     }
     .unwrap();
     assert_eq!(match_file_name, expected_file_name);

crates/fs/src/fake_git_repo.rs 🔗

@@ -790,7 +790,7 @@ impl GitRepository for FakeGitRepository {
     }
 
     fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
-        unimplemented!()
+        future::ready(Ok(String::new())).boxed()
     }
 
     fn diff_stat(

crates/fs/src/fs.rs 🔗

@@ -15,10 +15,14 @@ use gpui::Global;
 use gpui::ReadGlobal as _;
 use gpui::SharedString;
 use std::borrow::Cow;
+#[cfg(unix)]
+use std::ffi::CString;
 use util::command::new_command;
 
 #[cfg(unix)]
 use std::os::fd::{AsFd, AsRawFd};
+#[cfg(unix)]
+use std::os::unix::ffi::OsStrExt;
 
 #[cfg(unix)]
 use std::os::unix::fs::{FileTypeExt, MetadataExt};
@@ -143,7 +147,7 @@ pub trait Fs: Send + Sync {
         &self,
         abs_dot_git: &Path,
         system_git_binary_path: Option<&Path>,
-    ) -> Option<Arc<dyn GitRepository>>;
+    ) -> Result<Arc<dyn GitRepository>>;
     async fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String)
     -> Result<()>;
     async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>;
@@ -506,6 +510,63 @@ impl RealFs {
     }
 }
 
+#[cfg(any(target_os = "macos", target_os = "linux"))]
+fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> {
+    let source = path_to_c_string(source)?;
+    let target = path_to_c_string(target)?;
+
+    #[cfg(target_os = "macos")]
+    let result = unsafe { libc::renamex_np(source.as_ptr(), target.as_ptr(), libc::RENAME_EXCL) };
+
+    #[cfg(target_os = "linux")]
+    let result = unsafe {
+        libc::syscall(
+            libc::SYS_renameat2,
+            libc::AT_FDCWD,
+            source.as_ptr(),
+            libc::AT_FDCWD,
+            target.as_ptr(),
+            libc::RENAME_NOREPLACE,
+        )
+    };
+
+    if result == 0 {
+        Ok(())
+    } else {
+        Err(io::Error::last_os_error())
+    }
+}
+
+#[cfg(target_os = "windows")]
+fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> {
+    use std::os::windows::ffi::OsStrExt;
+
+    use windows::Win32::Storage::FileSystem::{MOVE_FILE_FLAGS, MoveFileExW};
+    use windows::core::PCWSTR;
+
+    let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
+    let target: Vec<u16> = target.as_os_str().encode_wide().chain(Some(0)).collect();
+
+    unsafe {
+        MoveFileExW(
+            PCWSTR(source.as_ptr()),
+            PCWSTR(target.as_ptr()),
+            MOVE_FILE_FLAGS::default(),
+        )
+    }
+    .map_err(|_| io::Error::last_os_error())
+}
+
+#[cfg(any(target_os = "macos", target_os = "linux"))]
+fn path_to_c_string(path: &Path) -> io::Result<CString> {
+    CString::new(path.as_os_str().as_bytes()).map_err(|_| {
+        io::Error::new(
+            io::ErrorKind::InvalidInput,
+            format!("path contains interior NUL: {}", path.display()),
+        )
+    })
+}
+
 #[async_trait::async_trait]
 impl Fs for RealFs {
     async fn create_dir(&self, path: &Path) -> Result<()> {
@@ -588,7 +649,56 @@ impl Fs for RealFs {
     }
 
     async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> {
-        if !options.overwrite && smol::fs::metadata(target).await.is_ok() {
+        if options.create_parents {
+            if let Some(parent) = target.parent() {
+                self.create_dir(parent).await?;
+            }
+        }
+
+        if options.overwrite {
+            smol::fs::rename(source, target).await?;
+            return Ok(());
+        }
+
+        let use_metadata_fallback = {
+            #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
+            {
+                let source = source.to_path_buf();
+                let target = target.to_path_buf();
+                match self
+                    .executor
+                    .spawn(async move { rename_without_replace(&source, &target) })
+                    .await
+                {
+                    Ok(()) => return Ok(()),
+                    Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
+                        if options.ignore_if_exists {
+                            return Ok(());
+                        }
+                        return Err(error.into());
+                    }
+                    Err(error)
+                        if error.raw_os_error().is_some_and(|code| {
+                            code == libc::ENOSYS
+                                || code == libc::ENOTSUP
+                                || code == libc::EOPNOTSUPP
+                        }) =>
+                    {
+                        // For case when filesystem or kernel does not support atomic no-overwrite rename.
+                        true
+                    }
+                    Err(error) => return Err(error.into()),
+                }
+            }
+
+            #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+            {
+                // For platforms which do not have an atomic no-overwrite rename yet.
+                true
+            }
+        };
+
+        if use_metadata_fallback && smol::fs::metadata(target).await.is_ok() {
             if options.ignore_if_exists {
                 return Ok(());
             } else {
@@ -596,12 +706,6 @@ impl Fs for RealFs {
             }
         }
 
-        if options.create_parents {
-            if let Some(parent) = target.parent() {
-                self.create_dir(parent).await?;
-            }
-        }
-
         smol::fs::rename(source, target).await?;
         Ok(())
     }
@@ -1045,8 +1149,8 @@ impl Fs for RealFs {
         &self,
         dotgit_path: &Path,
         system_git_binary_path: Option<&Path>,
-    ) -> Option<Arc<dyn GitRepository>> {
-        Some(Arc::new(RealGitRepository::new(
+    ) -> Result<Arc<dyn GitRepository>> {
+        Ok(Arc::new(RealGitRepository::new(
             dotgit_path,
             self.bundled_git_binary_path.clone(),
             system_git_binary_path.map(|path| path.to_path_buf()),
@@ -2762,9 +2866,7 @@ impl Fs for FakeFs {
         &self,
         abs_dot_git: &Path,
         _system_git_binary: Option<&Path>,
-    ) -> Option<Arc<dyn GitRepository>> {
-        use util::ResultExt as _;
-
+    ) -> Result<Arc<dyn GitRepository>> {
         self.with_git_state_and_paths(
             abs_dot_git,
             false,
@@ -2780,7 +2882,6 @@ impl Fs for FakeFs {
                 }) as _
             },
         )
-        .log_err()
     }
 
     async fn git_init(

crates/fs/tests/integration/fs.rs 🔗

@@ -523,6 +523,65 @@ async fn test_rename(executor: BackgroundExecutor) {
     );
 }
 
+#[gpui::test]
+#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
+async fn test_realfs_parallel_rename_without_overwrite_preserves_losing_source(
+    executor: BackgroundExecutor,
+) {
+    let temp_dir = TempDir::new().unwrap();
+    let root = temp_dir.path();
+    let source_a = root.join("dir_a/shared.txt");
+    let source_b = root.join("dir_b/shared.txt");
+    let target = root.join("shared.txt");
+
+    std::fs::create_dir_all(source_a.parent().unwrap()).unwrap();
+    std::fs::create_dir_all(source_b.parent().unwrap()).unwrap();
+    std::fs::write(&source_a, "from a").unwrap();
+    std::fs::write(&source_b, "from b").unwrap();
+
+    let fs = RealFs::new(None, executor);
+    let (first_result, second_result) = futures::future::join(
+        fs.rename(&source_a, &target, RenameOptions::default()),
+        fs.rename(&source_b, &target, RenameOptions::default()),
+    )
+    .await;
+
+    assert_ne!(first_result.is_ok(), second_result.is_ok());
+    assert!(target.exists());
+    assert_eq!(source_a.exists() as u8 + source_b.exists() as u8, 1);
+}
+
+#[gpui::test]
+#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
+async fn test_realfs_rename_ignore_if_exists_leaves_source_and_target_unchanged(
+    executor: BackgroundExecutor,
+) {
+    let temp_dir = TempDir::new().unwrap();
+    let root = temp_dir.path();
+    let source = root.join("source.txt");
+    let target = root.join("target.txt");
+
+    std::fs::write(&source, "from source").unwrap();
+    std::fs::write(&target, "from target").unwrap();
+
+    let fs = RealFs::new(None, executor);
+    let result = fs
+        .rename(
+            &source,
+            &target,
+            RenameOptions {
+                ignore_if_exists: true,
+                ..Default::default()
+            },
+        )
+        .await;
+
+    assert!(result.is_ok());
+
+    assert_eq!(std::fs::read_to_string(&source).unwrap(), "from source");
+    assert_eq!(std::fs::read_to_string(&target).unwrap(), "from target");
+}
+
 #[gpui::test]
 #[cfg(unix)]
 async fn test_realfs_broken_symlink_metadata(executor: BackgroundExecutor) {

crates/git/Cargo.toml 🔗

@@ -48,7 +48,6 @@ ztracing.workspace = true
 pretty_assertions.workspace = true
 serde_json.workspace = true
 text = { workspace = true, features = ["test-support"] }
-unindent.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 tempfile.workspace = true
 rand.workspace = true

crates/git/src/blame.rs 🔗

@@ -58,7 +58,7 @@ async fn run_git_blame(
     let mut child = {
         let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str());
         let _enter = span.enter();
-        git.build_command(["blame", "--incremental", "--contents", "-"])
+        git.build_command(&["blame", "--incremental", "--contents", "-"])
             .arg(path.as_unix_str())
             .stdin(Stdio::piped())
             .stdout(Stdio::piped())

crates/git/src/commit.rs 🔗

@@ -81,7 +81,7 @@ pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result<HashMa
 async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result<Vec<String>> {
     const MARKER: &str = "<MARKER>";
     let output = git
-        .build_command(["show"])
+        .build_command(&["show"])
         .arg("-s")
         .arg(format!("--format=%B{}", MARKER))
         .args(shas.iter().map(ToString::to_string))
@@ -91,7 +91,7 @@ async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result<Vec<String>>
     anyhow::ensure!(
         output.status.success(),
         "'git show' failed with error {:?}",
-        output.status
+        String::from_utf8_lossy(&output.stderr)
     );
     Ok(String::from_utf8_lossy(&output.stdout)
         .trim()

crates/git/src/git.rs 🔗

@@ -40,6 +40,9 @@ actions!(
         /// Restores the selected hunks to their original state.
         #[action(deprecated_aliases = ["editor::RevertSelectedHunks"])]
         Restore,
+        /// Restores the selected hunks to their original state and moves to the
+        /// next one.
+        RestoreAndNext,
         // per-file
         /// Shows git blame information for the current file.
         #[action(deprecated_aliases = ["editor::ToggleGitBlame"])]

crates/git/src/repository.rs 🔗

@@ -1000,11 +1000,18 @@ impl RealGitRepository {
         bundled_git_binary_path: Option<PathBuf>,
         system_git_binary_path: Option<PathBuf>,
         executor: BackgroundExecutor,
-    ) -> Option<Self> {
-        let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?;
-        let workdir_root = dotgit_path.parent()?;
-        let repository = git2::Repository::open(workdir_root).log_err()?;
-        Some(Self {
+    ) -> Result<Self> {
+        let any_git_binary_path = system_git_binary_path
+            .clone()
+            .or(bundled_git_binary_path)
+            .context("no git binary available")?;
+        log::info!(
+            "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}"
+        );
+        let workdir_root = dotgit_path.parent().context(".git has no parent")?;
+        let repository =
+            git2::Repository::open(workdir_root).context("creating libgit2 repository")?;
+        Ok(Self {
             repository: Arc::new(Mutex::new(repository)),
             system_git_binary_path,
             any_git_binary_path,
@@ -1039,7 +1046,7 @@ impl RealGitRepository {
         let git_binary = self.git_binary();
         let output: SharedString = self
             .executor
-            .spawn(async move { git_binary?.run(["help", "-a"]).await })
+            .spawn(async move { git_binary?.run(&["help", "-a"]).await })
             .await
             .unwrap_or_default()
             .into();
@@ -1086,9 +1093,12 @@ pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
     );
 
     cx.background_spawn(async move {
-        let name = git.run(["config", "--global", "user.name"]).await.log_err();
+        let name = git
+            .run(&["config", "--global", "user.name"])
+            .await
+            .log_err();
         let email = git
-            .run(["config", "--global", "user.email"])
+            .run(&["config", "--global", "user.email"])
             .await
             .log_err();
         GitCommitter { name, email }
@@ -1119,7 +1129,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command([
+                    .build_command(&[
                         "--no-optional-locks",
                         "show",
                         "--no-patch",
@@ -1157,7 +1167,7 @@ impl GitRepository for RealGitRepository {
         cx.background_spawn(async move {
             let git = git_binary?;
             let show_output = git
-                .build_command([
+                .build_command(&[
                     "--no-optional-locks",
                     "show",
                     "--format=",
@@ -1179,7 +1189,7 @@ impl GitRepository for RealGitRepository {
             let parent_sha = format!("{}^", commit);
 
             let mut cat_file_process = git
-                .build_command(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
+                .build_command(&["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
                 .stdin(Stdio::piped())
                 .stdout(Stdio::piped())
                 .stderr(Stdio::piped())
@@ -1295,7 +1305,7 @@ impl GitRepository for RealGitRepository {
 
             let git = git_binary?;
             let output = git
-                .build_command(["reset", mode_flag, &commit])
+                .build_command(&["reset", mode_flag, &commit])
                 .envs(env.iter())
                 .output()
                 .await?;
@@ -1323,7 +1333,7 @@ impl GitRepository for RealGitRepository {
 
             let git = git_binary?;
             let output = git
-                .build_command(["checkout", &commit, "--"])
+                .build_command(&["checkout", &commit, "--"])
                 .envs(env.iter())
                 .args(paths.iter().map(|path| path.as_unix_str()))
                 .output()
@@ -1427,7 +1437,7 @@ impl GitRepository for RealGitRepository {
 
                 if let Some(content) = content {
                     let mut child = git
-                        .build_command(["hash-object", "-w", "--stdin"])
+                        .build_command(&["hash-object", "-w", "--stdin"])
                         .envs(env.iter())
                         .stdin(Stdio::piped())
                         .stdout(Stdio::piped())
@@ -1442,7 +1452,7 @@ impl GitRepository for RealGitRepository {
                     log::debug!("indexing SHA: {sha}, path {path:?}");
 
                     let output = git
-                        .build_command(["update-index", "--add", "--cacheinfo", mode, sha])
+                        .build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
                         .envs(env.iter())
                         .arg(path.as_unix_str())
                         .output()
@@ -1456,7 +1466,7 @@ impl GitRepository for RealGitRepository {
                 } else {
                     log::debug!("removing path {path:?} from the index");
                     let output = git
-                        .build_command(["update-index", "--force-remove"])
+                        .build_command(&["update-index", "--force-remove"])
                         .envs(env.iter())
                         .arg(path.as_unix_str())
                         .output()
@@ -1491,7 +1501,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let mut process = git
-                    .build_command([
+                    .build_command(&[
                         "--no-optional-locks",
                         "cat-file",
                         "--batch-check=%(objectname)",
@@ -1551,7 +1561,7 @@ impl GitRepository for RealGitRepository {
         let args = git_status_args(path_prefixes);
         log::debug!("Checking for git status in {path_prefixes:?}");
         self.executor.spawn(async move {
-            let output = git.build_command(args).output().await?;
+            let output = git.build_command(&args).output().await?;
             if output.status.success() {
                 let stdout = String::from_utf8_lossy(&output.stdout);
                 stdout.parse()
@@ -1589,7 +1599,7 @@ impl GitRepository for RealGitRepository {
 
         self.executor
             .spawn(async move {
-                let output = git.build_command(args).output().await?;
+                let output = git.build_command(&args).output().await?;
                 if output.status.success() {
                     let stdout = String::from_utf8_lossy(&output.stdout);
                     stdout.parse()
@@ -1645,7 +1655,7 @@ impl GitRepository for RealGitRepository {
                     &fields,
                 ];
                 let git = git_binary?;
-                let output = git.build_command(args).output().await?;
+                let output = git.build_command(&args).output().await?;
 
                 anyhow::ensure!(
                     output.status.success(),
@@ -1659,7 +1669,7 @@ impl GitRepository for RealGitRepository {
                 if branches.is_empty() {
                     let args = vec!["symbolic-ref", "--quiet", "HEAD"];
 
-                    let output = git.build_command(args).output().await?;
+                    let output = git.build_command(&args).output().await?;
 
                     // git symbolic-ref returns a non-0 exit code if HEAD points
                     // to something other than a branch
@@ -1727,7 +1737,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?;
                 let git = git_binary?;
-                let output = git.build_command(args).output().await?;
+                let output = git.build_command(&args).output().await?;
                 if output.status.success() {
                     Ok(())
                 } else {
@@ -1753,7 +1763,7 @@ impl GitRepository for RealGitRepository {
                 }
                 args.push("--".into());
                 args.push(path.as_os_str().into());
-                git_binary?.run(args).await?;
+                git_binary?.run(&args).await?;
                 anyhow::Ok(())
             })
             .boxed()
@@ -1772,7 +1782,7 @@ impl GitRepository for RealGitRepository {
                     old_path.as_os_str().into(),
                     new_path.as_os_str().into(),
                 ];
-                git_binary?.run(args).await?;
+                git_binary?.run(&args).await?;
                 anyhow::Ok(())
             })
             .boxed()
@@ -1975,11 +1985,11 @@ impl GitRepository for RealGitRepository {
                 let git = git_binary?;
                 let output = match diff {
                     DiffType::HeadToIndex => {
-                        git.build_command(["diff", "--staged"]).output().await?
+                        git.build_command(&["diff", "--staged"]).output().await?
                     }
-                    DiffType::HeadToWorktree => git.build_command(["diff"]).output().await?,
+                    DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
                     DiffType::MergeBase { base_ref } => {
-                        git.build_command(["diff", "--merge-base", base_ref.as_ref()])
+                        git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
                             .output()
                             .await?
                     }
@@ -2036,7 +2046,7 @@ impl GitRepository for RealGitRepository {
                 if !paths.is_empty() {
                     let git = git_binary?;
                     let output = git
-                        .build_command(["update-index", "--add", "--remove", "--"])
+                        .build_command(&["update-index", "--add", "--remove", "--"])
                         .envs(env.iter())
                         .args(paths.iter().map(|p| p.as_unix_str()))
                         .output()
@@ -2064,7 +2074,7 @@ impl GitRepository for RealGitRepository {
                 if !paths.is_empty() {
                     let git = git_binary?;
                     let output = git
-                        .build_command(["reset", "--quiet", "--"])
+                        .build_command(&["reset", "--quiet", "--"])
                         .envs(env.iter())
                         .args(paths.iter().map(|p| p.as_std_path()))
                         .output()
@@ -2091,7 +2101,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command(["stash", "push", "--quiet", "--include-untracked"])
+                    .build_command(&["stash", "push", "--quiet", "--include-untracked"])
                     .envs(env.iter())
                     .args(paths.iter().map(|p| p.as_unix_str()))
                     .output()
@@ -2196,7 +2206,7 @@ impl GitRepository for RealGitRepository {
         // which we want to block on.
         async move {
             let git = git_binary?;
-            let mut cmd = git.build_command(["commit", "--quiet", "-m"]);
+            let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
             cmd.envs(env.iter())
                 .arg(&message.to_string())
                 .arg("--cleanup=strip")
@@ -2248,7 +2258,7 @@ impl GitRepository for RealGitRepository {
                 executor.clone(),
                 is_trusted,
             );
-            let mut command = git.build_command(["push"]);
+            let mut command = git.build_command(&["push"]);
             command
                 .envs(env.iter())
                 .args(options.map(|option| match option {
@@ -2290,7 +2300,7 @@ impl GitRepository for RealGitRepository {
                 executor.clone(),
                 is_trusted,
             );
-            let mut command = git.build_command(["pull"]);
+            let mut command = git.build_command(&["pull"]);
             command.envs(env.iter());
 
             if rebase {
@@ -2331,7 +2341,7 @@ impl GitRepository for RealGitRepository {
                 executor.clone(),
                 is_trusted,
             );
-            let mut command = git.build_command(["fetch", &remote_name]);
+            let mut command = git.build_command(&["fetch", &remote_name]);
             command
                 .envs(env.iter())
                 .stdout(Stdio::piped())
@@ -2348,7 +2358,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command(["rev-parse", "--abbrev-ref"])
+                    .build_command(&["rev-parse", "--abbrev-ref"])
                     .arg(format!("{branch}@{{push}}"))
                     .output()
                     .await?;
@@ -2373,7 +2383,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command(["config", "--get"])
+                    .build_command(&["config", "--get"])
                     .arg(format!("branch.{branch}.remote"))
                     .output()
                     .await?;
@@ -2394,7 +2404,7 @@ impl GitRepository for RealGitRepository {
         self.executor
             .spawn(async move {
                 let git = git_binary?;
-                let output = git.build_command(["remote", "-v"]).output().await?;
+                let output = git.build_command(&["remote", "-v"]).output().await?;
 
                 anyhow::ensure!(
                     output.status.success(),
@@ -2725,7 +2735,7 @@ impl GitRepository for RealGitRepository {
         async move {
             let git = git_binary?;
 
-            let mut command = git.build_command([
+            let mut command = git.build_command(&[
                 "log",
                 GRAPH_COMMIT_FORMAT,
                 log_order.as_arg(),
@@ -2808,7 +2818,7 @@ async fn run_commit_data_reader(
     request_rx: smol::channel::Receiver<CommitDataRequest>,
 ) -> Result<()> {
     let mut process = git
-        .build_command(["--no-optional-locks", "cat-file", "--batch"])
+        .build_command(&["--no-optional-locks", "cat-file", "--batch"])
         .stdin(Stdio::piped())
         .stdout(Stdio::piped())
         .stderr(Stdio::piped())
@@ -3075,7 +3085,7 @@ impl GitBinary {
             .join(format!("index-{}.tmp", id))
     }
 
-    pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
+    pub async fn run<S>(&self, args: &[S]) -> Result<String>
     where
         S: AsRef<OsStr>,
     {
@@ -3087,7 +3097,7 @@ impl GitBinary {
     }
 
     /// Returns the result of the command without trimming the trailing newline.
-    pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
+    pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
     where
         S: AsRef<OsStr>,
     {
@@ -3105,10 +3115,7 @@ impl GitBinary {
     }
 
     #[allow(clippy::disallowed_methods)]
-    pub(crate) fn build_command<S>(
-        &self,
-        args: impl IntoIterator<Item = S>,
-    ) -> util::command::Command
+    pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
     where
         S: AsRef<OsStr>,
     {
@@ -3125,6 +3132,14 @@ impl GitBinary {
             command.args(["-c", "diff.external="]);
         }
         command.args(args);
+
+        // If the `diff` command is being used, we'll want to add the
+        // `--no-ext-diff` flag when working on an untrusted repository,
+        // preventing any external diff programs from being invoked.
+        if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
+            command.arg("--no-ext-diff");
+        }
+
         if let Some(index_file_path) = self.index_file_path.as_ref() {
             command.env("GIT_INDEX_FILE", index_file_path);
         }
@@ -3394,7 +3409,7 @@ mod tests {
             false,
         );
         let output = git
-            .build_command(["version"])
+            .build_command(&["version"])
             .output()
             .await
             .expect("git version should succeed");
@@ -3407,7 +3422,7 @@ mod tests {
             false,
         );
         let output = git
-            .build_command(["config", "--get", "core.fsmonitor"])
+            .build_command(&["config", "--get", "core.fsmonitor"])
             .output()
             .await
             .expect("git config should run");
@@ -3426,7 +3441,7 @@ mod tests {
             false,
         );
         let output = git
-            .build_command(["config", "--get", "core.hooksPath"])
+            .build_command(&["config", "--get", "core.hooksPath"])
             .output()
             .await
             .expect("git config should run");
@@ -3451,7 +3466,7 @@ mod tests {
             true,
         );
         let output = git
-            .build_command(["config", "--get", "core.fsmonitor"])
+            .build_command(&["config", "--get", "core.fsmonitor"])
             .output()
             .await
             .expect("git config should run");
@@ -3469,7 +3484,7 @@ mod tests {
             true,
         );
         let output = git
-            .build_command(["config", "--get", "core.hooksPath"])
+            .build_command(&["config", "--get", "core.hooksPath"])
             .output()
             .await
             .expect("git config should run");

crates/git_graph/Cargo.toml 🔗

@@ -43,7 +43,6 @@ git = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 rand.workspace = true
-recent_projects = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }

crates/git_graph/src/git_graph.rs 🔗

@@ -15,7 +15,7 @@ use gpui::{
     px, uniform_list,
 };
 use language::line_diff;
-use menu::{Cancel, SelectNext, SelectPrevious};
+use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::{
     Project,
     git_store::{
@@ -1171,22 +1171,35 @@ impl GitGraph {
         cx.notify();
     }
 
-    fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
+        self.select_entry(0, cx);
+    }
+
+    fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(selected_entry_idx) = &self.selected_entry_idx {
             self.select_entry(selected_entry_idx.saturating_sub(1), cx);
         } else {
-            self.select_entry(0, cx);
+            self.select_first(&SelectFirst, window, cx);
         }
     }
 
     fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(selected_entry_idx) = &self.selected_entry_idx {
-            self.select_entry(selected_entry_idx.saturating_add(1), cx);
+            self.select_entry(
+                selected_entry_idx
+                    .saturating_add(1)
+                    .min(self.graph_data.commits.len().saturating_sub(1)),
+                cx,
+            );
         } else {
             self.select_prev(&SelectPrevious, window, cx);
         }
     }
 
+    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx);
+    }
+
     fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
         self.open_selected_commit_view(window, cx);
     }
@@ -1481,10 +1494,9 @@ impl GitGraph {
 
                                 this.child(
                                     Button::new("author-email-copy", author_email.clone())
-                                        .icon(icon)
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(icon_color)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(
+                                            Icon::new(icon).size(IconSize::Small).color(icon_color),
+                                        )
                                         .label_size(LabelSize::Small)
                                         .truncate(true)
                                         .color(Color::Muted)
@@ -1529,10 +1541,9 @@ impl GitGraph {
                                 };
 
                                 Button::new("sha-button", &full_sha)
-                                    .icon(icon)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(icon_color)
-                                    .icon_position(IconPosition::Start)
+                                    .start_icon(
+                                        Icon::new(icon).size(IconSize::Small).color(icon_color),
+                                    )
                                     .label_size(LabelSize::Small)
                                     .truncate(true)
                                     .color(Color::Muted)
@@ -1589,10 +1600,9 @@ impl GitGraph {
                                         "view-on-provider",
                                         format!("View on {}", provider_name),
                                     )
-                                    .icon(icon)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(Color::Muted)
-                                    .icon_position(IconPosition::Start)
+                                    .start_icon(
+                                        Icon::new(icon).size(IconSize::Small).color(Color::Muted),
+                                    )
                                     .label_size(LabelSize::Small)
                                     .truncate(true)
                                     .color(Color::Muted)
@@ -2260,8 +2270,10 @@ impl Render for GitGraph {
                 this.open_selected_commit_view(window, cx);
             }))
             .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::select_first))
             .on_action(cx.listener(Self::select_prev))
             .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_last))
             .on_action(cx.listener(Self::confirm))
             .child(content)
             .children(self.context_menu.as_ref().map(|(menu, position, _)| {

crates/git_ui/Cargo.toml 🔗

@@ -73,7 +73,6 @@ windows.workspace = true
 [dev-dependencies]
 ctor.workspace = true
 editor = { workspace = true, features = ["test-support"] }
-git_hosting_providers.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 pretty_assertions.workspace = true

crates/git_ui/src/blame_ui.rs 🔗

@@ -322,10 +322,11 @@ impl BlameRenderer for GitBlameRenderer {
                                                         format!("#{}", pr.number),
                                                     )
                                                     .color(Color::Muted)
-                                                    .icon(IconName::PullRequest)
-                                                    .icon_color(Color::Muted)
-                                                    .icon_position(IconPosition::Start)
-                                                    .icon_size(IconSize::Small)
+                                                    .start_icon(
+                                                        Icon::new(IconName::PullRequest)
+                                                            .size(IconSize::Small)
+                                                            .color(Color::Muted),
+                                                    )
                                                     .on_click(move |_, _, cx| {
                                                         cx.stop_propagation();
                                                         cx.open_url(pr.url.as_str())
@@ -339,10 +340,11 @@ impl BlameRenderer for GitBlameRenderer {
                                                     short_commit_id.clone(),
                                                 )
                                                 .color(Color::Muted)
-                                                .icon(IconName::FileGit)
-                                                .icon_color(Color::Muted)
-                                                .icon_position(IconPosition::Start)
-                                                .icon_size(IconSize::Small)
+                                                .start_icon(
+                                                    Icon::new(IconName::FileGit)
+                                                        .size(IconSize::Small)
+                                                        .color(Color::Muted),
+                                                )
                                                 .on_click(move |_, window, cx| {
                                                     CommitView::open(
                                                         commit_summary.sha.clone().into(),

crates/git_ui/src/commit_modal.rs 🔗

@@ -366,11 +366,12 @@ impl CommitModal {
             .unwrap_or_else(|| "<no branch>".to_owned());
 
         let branch_picker_button = panel_button(branch)
-            .icon(IconName::GitBranch)
-            .icon_size(IconSize::Small)
-            .icon_color(Color::Placeholder)
+            .start_icon(
+                Icon::new(IconName::GitBranch)
+                    .size(IconSize::Small)
+                    .color(Color::Placeholder),
+            )
             .color(Color::Muted)
-            .icon_position(IconPosition::Start)
             .on_click(cx.listener(|_, _, window, cx| {
                 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
             }))

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -336,9 +336,10 @@ impl Render for CommitTooltip {
                                                     format!("#{}", pr.number),
                                                 )
                                                 .color(Color::Muted)
-                                                .icon(IconName::PullRequest)
-                                                .icon_color(Color::Muted)
-                                                .icon_position(IconPosition::Start)
+                                                .start_icon(
+                                                    Icon::new(IconName::PullRequest)
+                                                        .color(Color::Muted),
+                                                )
                                                 .style(ButtonStyle::Subtle)
                                                 .on_click(move |_, _, cx| {
                                                     cx.stop_propagation();
@@ -354,9 +355,9 @@ impl Render for CommitTooltip {
                                             )
                                             .style(ButtonStyle::Subtle)
                                             .color(Color::Muted)
-                                            .icon(IconName::FileGit)
-                                            .icon_color(Color::Muted)
-                                            .icon_position(IconPosition::Start)
+                                            .start_icon(
+                                                Icon::new(IconName::FileGit).color(Color::Muted),
+                                            )
                                             .on_click(
                                                 move |_, window, cx| {
                                                     CommitView::open(

crates/git_ui/src/commit_view.rs 🔗

@@ -524,10 +524,11 @@ impl CommitView {
             .when(self.stash.is_none(), |this| {
                 this.child(
                     Button::new("sha", "Commit SHA")
-                        .icon(copy_icon)
-                        .icon_color(copy_icon_color)
-                        .icon_position(IconPosition::Start)
-                        .icon_size(IconSize::Small)
+                        .start_icon(
+                            Icon::new(copy_icon)
+                                .size(IconSize::Small)
+                                .color(copy_icon_color),
+                        )
                         .tooltip({
                             let commit_sha = commit_sha.clone();
                             move |_, cx| {

crates/git_ui/src/conflict_view.rs 🔗

@@ -1,3 +1,4 @@
+use agent_settings::AgentSettings;
 use collections::{HashMap, HashSet};
 use editor::{
     ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker,
@@ -5,14 +6,25 @@ use editor::{
     display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
 };
 use gpui::{
-    App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task,
-    WeakEntity,
+    App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription,
+    Task, WeakEntity,
 };
 use language::{Anchor, Buffer, BufferId};
-use project::{ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _};
-use std::{ops::Range, sync::Arc};
-use ui::{ActiveTheme, Element as _, Styled, Window, prelude::*};
+use project::{
+    ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _,
+    git_store::{GitStoreEvent, RepositoryEvent},
+};
+use settings::Settings;
+use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
+use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*};
 use util::{ResultExt as _, debug_panic, maybe};
+use workspace::{
+    Workspace,
+    notifications::{NotificationId, simple_message_notification::MessageNotification},
+};
+use zed_actions::agent::{
+    ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
+};
 
 pub(crate) struct ConflictAddon {
     buffers: HashMap<BufferId, BufferConflicts>,
@@ -182,7 +194,7 @@ fn conflicts_updated(
     let excerpts = multibuffer.excerpts_for_buffer(buffer_id, cx);
     let Some(buffer_snapshot) = excerpts
         .first()
-        .and_then(|(excerpt_id, _)| snapshot.buffer_for_excerpt(*excerpt_id))
+        .and_then(|(excerpt_id, _, _)| snapshot.buffer_for_excerpt(*excerpt_id))
     else {
         return;
     };
@@ -221,7 +233,7 @@ fn conflicts_updated(
         let mut removed_highlighted_ranges = Vec::new();
         let mut removed_block_ids = HashSet::default();
         for (conflict_range, block_id) in old_conflicts {
-            let Some((excerpt_id, _)) = excerpts.iter().find(|(_, range)| {
+            let Some((excerpt_id, _, _)) = excerpts.iter().find(|(_, _, range)| {
                 let precedes_start = range
                     .context
                     .start
@@ -263,7 +275,7 @@ fn conflicts_updated(
     let new_conflicts = &conflict_set.conflicts[event.new_range.clone()];
     let mut blocks = Vec::new();
     for conflict in new_conflicts {
-        let Some((excerpt_id, _)) = excerpts.iter().find(|(_, range)| {
+        let Some((excerpt_id, _, _)) = excerpts.iter().find(|(_, _, range)| {
             let precedes_start = range
                 .context
                 .start
@@ -368,11 +380,12 @@ fn render_conflict_buttons(
     editor: WeakEntity<Editor>,
     cx: &mut BlockContext,
 ) -> AnyElement {
+    let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
+
     h_flex()
         .id(cx.block_id)
         .h(cx.line_height)
         .ml(cx.margins.gutter.width)
-        .items_end()
         .gap_1()
         .bg(cx.theme().colors().editor_background)
         .child(
@@ -419,6 +432,7 @@ fn render_conflict_buttons(
             Button::new("both", "Use Both")
                 .label_size(LabelSize::Small)
                 .on_click({
+                    let editor = editor.clone();
                     let conflict = conflict.clone();
                     let ours = conflict.ours.clone();
                     let theirs = conflict.theirs.clone();
@@ -435,9 +449,147 @@ fn render_conflict_buttons(
                     }
                 }),
         )
+        .when(is_ai_enabled, |this| {
+            this.child(Divider::vertical()).child(
+                Button::new("resolve-with-agent", "Resolve with Agent")
+                    .label_size(LabelSize::Small)
+                    .start_icon(
+                        Icon::new(IconName::ZedAssistant)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .on_click({
+                        let conflict = conflict.clone();
+                        move |_, window, cx| {
+                            let content = editor
+                                .update(cx, |editor, cx| {
+                                    let multibuffer = editor.buffer().read(cx);
+                                    let buffer_id = conflict.ours.end.buffer_id?;
+                                    let buffer = multibuffer.buffer(buffer_id)?;
+                                    let buffer_read = buffer.read(cx);
+                                    let snapshot = buffer_read.snapshot();
+                                    let conflict_text = snapshot
+                                        .text_for_range(conflict.range.clone())
+                                        .collect::<String>();
+                                    let file_path = buffer_read
+                                        .file()
+                                        .and_then(|file| file.as_local())
+                                        .map(|f| f.abs_path(cx).to_string_lossy().to_string())
+                                        .unwrap_or_default();
+                                    Some(ConflictContent {
+                                        file_path,
+                                        conflict_text,
+                                        ours_branch_name: conflict.ours_branch_name.to_string(),
+                                        theirs_branch_name: conflict.theirs_branch_name.to_string(),
+                                    })
+                                })
+                                .ok()
+                                .flatten();
+                            if let Some(content) = content {
+                                window.dispatch_action(
+                                    Box::new(ResolveConflictsWithAgent {
+                                        conflicts: vec![content],
+                                    }),
+                                    cx,
+                                );
+                            }
+                        }
+                    }),
+            )
+        })
         .into_any()
 }
 
+struct MergeConflictNotification;
+
+fn merge_conflict_notification_id() -> NotificationId {
+    NotificationId::unique::<MergeConflictNotification>()
+}
+
+fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec<String> {
+    let project = workspace.project().read(cx);
+    let git_store = project.git_store().read(cx);
+    let mut paths = Vec::new();
+
+    for repo in git_store.repositories().values() {
+        let snapshot = repo.read(cx).snapshot();
+        for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() {
+            if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) {
+                paths.push(
+                    project_path
+                        .path
+                        .as_std_path()
+                        .to_string_lossy()
+                        .to_string(),
+                );
+            }
+        }
+    }
+
+    paths
+}
+
+pub(crate) fn register_conflict_notification(
+    workspace: &mut Workspace,
+    cx: &mut Context<Workspace>,
+) {
+    let git_store = workspace.project().read(cx).git_store().clone();
+
+    let last_shown_paths: Rc<RefCell<HashSet<String>>> = Rc::new(RefCell::new(HashSet::default()));
+
+    cx.subscribe(&git_store, move |workspace, _git_store, event, cx| {
+        let conflicts_changed = matches!(
+            event,
+            GitStoreEvent::ConflictsUpdated
+                | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
+        );
+        if !AgentSettings::get_global(cx).enabled || !conflicts_changed {
+            return;
+        }
+
+        let paths = collect_conflicted_file_paths(workspace, cx);
+        let notification_id = merge_conflict_notification_id();
+        let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
+
+        if paths.is_empty() {
+            last_shown_paths.borrow_mut().clear();
+            workspace.dismiss_notification(&notification_id, cx);
+        } else if *last_shown_paths.borrow() != current_paths_set {
+            // Only show the notification if the set of conflicted paths has changed.
+            // This prevents re-showing after the user dismisses it while working on the same conflicts.
+            *last_shown_paths.borrow_mut() = current_paths_set;
+            let file_count = paths.len();
+            workspace.show_notification(notification_id, cx, |cx| {
+                cx.new(|cx| {
+                    let message = if file_count == 1 {
+                        "1 file has unresolved merge conflicts".to_string()
+                    } else {
+                        format!("{file_count} files have unresolved merge conflicts")
+                    };
+
+                    MessageNotification::new(message, cx)
+                        .primary_message("Resolve with Agent")
+                        .primary_icon(IconName::ZedAssistant)
+                        .primary_icon_color(Color::Muted)
+                        .primary_on_click({
+                            let paths = paths.clone();
+                            move |window, cx| {
+                                window.dispatch_action(
+                                    Box::new(ResolveConflictedFilesWithAgent {
+                                        conflicted_file_paths: paths.clone(),
+                                    }),
+                                    cx,
+                                );
+                                cx.emit(DismissEvent);
+                            }
+                        })
+                })
+            });
+        }
+    })
+    .detach();
+}
+
 pub(crate) fn resolve_conflict(
     editor: WeakEntity<Editor>,
     excerpt_id: ExcerptId,

crates/git_ui/src/file_diff_view.rs 🔗

@@ -6,9 +6,9 @@ use editor::{Editor, EditorEvent, MultiBuffer};
 use futures::{FutureExt, select_biased};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
-    Focusable, IntoElement, Render, Task, WeakEntity, Window,
+    Focusable, Font, IntoElement, Render, Task, WeakEntity, Window,
 };
-use language::{Buffer, LanguageRegistry};
+use language::{Buffer, HighlightedText, LanguageRegistry};
 use project::Project;
 use std::{
     any::{Any, TypeId},
@@ -21,7 +21,7 @@ use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
 use util::paths::PathExt as _;
 use workspace::{
     Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+    item::{ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -108,7 +108,7 @@ impl FileDiffView {
 
         for buffer in [&old_buffer, &new_buffer] {
             cx.subscribe(buffer, move |this, _, event, _| match event {
-                language::BufferEvent::Edited
+                language::BufferEvent::Edited { .. }
                 | language::BufferEvent::LanguageChanged(_)
                 | language::BufferEvent::Reparsed => {
                     this.buffer_changes_tx.send(()).ok();
@@ -324,7 +324,7 @@ impl Item for FileDiffView {
         ToolbarItemLocation::PrimaryLeft
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.editor.breadcrumbs(cx)
     }
 

crates/git_ui/src/file_history_view.rs 🔗

@@ -429,10 +429,11 @@ impl Render for FileHistoryView {
                                     Button::new("load-more", "Load More")
                                         .disabled(self.loading_more)
                                         .label_size(LabelSize::Small)
-                                        .icon(IconName::ArrowCircle)
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(Color::Muted)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(
+                                            Icon::new(IconName::ArrowCircle)
+                                                .size(IconSize::Small)
+                                                .color(Color::Muted),
+                                        )
                                         .on_click(cx.listener(|this, _, window, cx| {
                                             this.load_more(window, cx);
                                         })),
@@ -565,7 +566,10 @@ impl Item for FileHistoryView {
         false
     }
 
-    fn breadcrumbs(&self, _cx: &App) -> Option<Vec<workspace::item::BreadcrumbText>> {
+    fn breadcrumbs(
+        &self,
+        _cx: &App,
+    ) -> Option<(Vec<workspace::item::HighlightedText>, Option<gpui::Font>)> {
         None
     }
 

crates/git_ui/src/git_ui.rs 🔗

@@ -62,6 +62,7 @@ pub fn init(cx: &mut App) {
         git_panel::register(workspace);
         repository_selector::register(workspace);
         git_picker::register(workspace);
+        conflict_view::register_conflict_notification(workspace, cx);
 
         let project = workspace.project().read(cx);
         if project.is_read_only(cx) {
@@ -871,8 +872,7 @@ impl Render for GitCloneModal {
                     .child(
                         Button::new("learn-more", "Learn More")
                             .label_size(LabelSize::Small)
-                            .icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::XSmall)
+                            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall))
                             .on_click(|_, _, cx| {
                                 cx.open_url("https://github.com/git-guides/git-clone");
                             }),

crates/git_ui/src/multi_diff_view.rs 🔗

@@ -3,9 +3,9 @@ use buffer_diff::BufferDiff;
 use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
-    Focusable, IntoElement, Render, SharedString, Task, Window,
+    Focusable, Font, IntoElement, Render, SharedString, Task, Window,
 };
-use language::{Buffer, Capability, OffsetRangeExt};
+use language::{Buffer, Capability, HighlightedText, OffsetRangeExt};
 use multi_buffer::PathKey;
 use project::Project;
 use std::{
@@ -18,7 +18,7 @@ use util::paths::PathStyle;
 use util::rel_path::RelPath;
 use workspace::{
     Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+    item::{ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -338,7 +338,7 @@ impl Item for MultiDiffView {
         ToolbarItemLocation::PrimaryLeft
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.editor.breadcrumbs(cx)
     }
 

crates/git_ui/src/project_diff.rs 🔗

@@ -2,7 +2,6 @@ use crate::{
     conflict_view::ConflictAddon,
     git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
     git_panel_settings::GitPanelSettings,
-    remote_button::{render_publish_button, render_push_button},
     resolve_active_repository,
 };
 use agent_settings::AgentSettings;
@@ -18,8 +17,7 @@ use editor::{
 use git::repository::DiffType;
 
 use git::{
-    Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
-    repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus},
+    Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath,
     status::FileStatus,
 };
 use gpui::{
@@ -517,7 +515,11 @@ impl ProjectDiff {
     fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.editor.update(cx, |editor, cx| {
             editor.rhs_editor().update(cx, |editor, cx| {
-                editor.move_to_beginning(&Default::default(), window, cx);
+                editor.change_selections(Default::default(), window, cx, |s| {
+                    s.select_ranges(vec![
+                        multi_buffer::Anchor::min()..multi_buffer::Anchor::min(),
+                    ]);
+                });
             });
         });
     }
@@ -1590,8 +1592,11 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH
         "send-review",
         format!("Send Review to Agent ({})", review_count),
     )
-    .icon(IconName::ZedAssistant)
-    .icon_position(IconPosition::Start)
+    .start_icon(
+        Icon::new(IconName::ZedAssistant)
+            .size(IconSize::Small)
+            .color(Color::Muted),
+    )
     .tooltip(Tooltip::for_action_title_in(
         "Send all review comments to the Agent panel",
         &SendReviewToAgent,
@@ -1684,10 +1689,11 @@ impl Render for BranchDiffToolbar {
                 let focus_handle = focus_handle.clone();
                 this.child(Divider::vertical()).child(
                     Button::new("review-diff", "Review Diff")
-                        .icon(IconName::ZedAssistant)
-                        .icon_position(IconPosition::Start)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
+                        .start_icon(
+                            Icon::new(IconName::ZedAssistant)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
                         .tooltip(move |_, cx| {
                             Tooltip::with_meta_in(
@@ -1715,254 +1721,6 @@ impl Render for BranchDiffToolbar {
     }
 }
 
-#[derive(IntoElement, RegisterComponent)]
-pub struct ProjectDiffEmptyState {
-    pub no_repo: bool,
-    pub can_push_and_pull: bool,
-    pub focus_handle: Option<FocusHandle>,
-    pub current_branch: Option<Branch>,
-    // has_pending_commits: bool,
-    // ahead_of_remote: bool,
-    // no_git_repository: bool,
-}
-
-impl RenderOnce for ProjectDiffEmptyState {
-    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool {
-            matches!(self.current_branch, Some(Branch {
-                    upstream:
-                        Some(Upstream {
-                            tracking:
-                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
-                                    ahead, behind, ..
-                                }),
-                            ..
-                        }),
-                    ..
-                }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0))
-        };
-
-        let change_count = |current_branch: &Branch| -> (usize, usize) {
-            match current_branch {
-                Branch {
-                    upstream:
-                        Some(Upstream {
-                            tracking:
-                                UpstreamTracking::Tracked(UpstreamTrackingStatus {
-                                    ahead, behind, ..
-                                }),
-                            ..
-                        }),
-                    ..
-                } => (*ahead as usize, *behind as usize),
-                _ => (0, 0),
-            }
-        };
-
-        let not_ahead_or_behind = status_against_remote(0, 0);
-        let ahead_of_remote = status_against_remote(1, 0);
-        let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() {
-            branch.upstream.is_none()
-        } else {
-            false
-        };
-
-        let has_branch_container = |branch: &Branch| {
-            h_flex()
-                .max_w(px(420.))
-                .bg(cx.theme().colors().text.opacity(0.05))
-                .border_1()
-                .border_color(cx.theme().colors().border)
-                .rounded_sm()
-                .gap_8()
-                .px_6()
-                .py_4()
-                .map(|this| {
-                    if ahead_of_remote {
-                        let ahead_count = change_count(branch).0;
-                        let ahead_string = format!("{} Commits Ahead", ahead_count);
-                        this.child(
-                            v_flex()
-                                .child(Headline::new(ahead_string).size(HeadlineSize::Small))
-                                .child(
-                                    Label::new(format!("Push your changes to {}", branch.name()))
-                                        .color(Color::Muted),
-                                ),
-                        )
-                        .child(div().child(render_push_button(
-                            self.focus_handle,
-                            "push".into(),
-                            ahead_count as u32,
-                        )))
-                    } else if branch_not_on_remote {
-                        this.child(
-                            v_flex()
-                                .child(Headline::new("Publish Branch").size(HeadlineSize::Small))
-                                .child(
-                                    Label::new(format!("Create {} on remote", branch.name()))
-                                        .color(Color::Muted),
-                                ),
-                        )
-                        .child(
-                            div().child(render_publish_button(self.focus_handle, "publish".into())),
-                        )
-                    } else {
-                        this.child(Label::new("Remote status unknown").color(Color::Muted))
-                    }
-                })
-        };
-
-        v_flex().size_full().items_center().justify_center().child(
-            v_flex()
-                .gap_1()
-                .when(self.no_repo, |this| {
-                    this.text_center()
-                        .child(Label::new("No Repository").color(Color::Muted))
-                        .child(
-                            Button::new("initialize-repo", "Initialize Repository")
-                                .on_click(move |_, _, cx| cx.dispatch_action(&git::Init)),
-                        )
-                })
-                .map(|this| {
-                    if not_ahead_or_behind && self.current_branch.is_some() {
-                        this.text_center()
-                            .child(Label::new("No Changes").color(Color::Muted))
-                    } else {
-                        this.when_some(self.current_branch.as_ref(), |this, branch| {
-                            this.child(has_branch_container(branch))
-                        })
-                    }
-                }),
-        )
-    }
-}
-
-mod preview {
-    use git::repository::{
-        Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus,
-    };
-    use ui::prelude::*;
-
-    use super::ProjectDiffEmptyState;
-
-    // View this component preview using `workspace: open component-preview`
-    impl Component for ProjectDiffEmptyState {
-        fn scope() -> ComponentScope {
-            ComponentScope::VersionControl
-        }
-
-        fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
-            let unknown_upstream: Option<UpstreamTracking> = None;
-            let ahead_of_upstream: Option<UpstreamTracking> = Some(
-                UpstreamTrackingStatus {
-                    ahead: 2,
-                    behind: 0,
-                }
-                .into(),
-            );
-
-            let not_ahead_or_behind_upstream: Option<UpstreamTracking> = Some(
-                UpstreamTrackingStatus {
-                    ahead: 0,
-                    behind: 0,
-                }
-                .into(),
-            );
-
-            fn branch(upstream: Option<UpstreamTracking>) -> Branch {
-                Branch {
-                    is_head: true,
-                    ref_name: "some-branch".into(),
-                    upstream: upstream.map(|tracking| Upstream {
-                        ref_name: "origin/some-branch".into(),
-                        tracking,
-                    }),
-                    most_recent_commit: Some(CommitSummary {
-                        sha: "abc123".into(),
-                        subject: "Modify stuff".into(),
-                        commit_timestamp: 1710932954,
-                        author_name: "John Doe".into(),
-                        has_parent: true,
-                    }),
-                }
-            }
-
-            let no_repo_state = ProjectDiffEmptyState {
-                no_repo: true,
-                can_push_and_pull: false,
-                focus_handle: None,
-                current_branch: None,
-            };
-
-            let no_changes_state = ProjectDiffEmptyState {
-                no_repo: false,
-                can_push_and_pull: true,
-                focus_handle: None,
-                current_branch: Some(branch(not_ahead_or_behind_upstream)),
-            };
-
-            let ahead_of_upstream_state = ProjectDiffEmptyState {
-                no_repo: false,
-                can_push_and_pull: true,
-                focus_handle: None,
-                current_branch: Some(branch(ahead_of_upstream)),
-            };
-
-            let unknown_upstream_state = ProjectDiffEmptyState {
-                no_repo: false,
-                can_push_and_pull: true,
-                focus_handle: None,
-                current_branch: Some(branch(unknown_upstream)),
-            };
-
-            let (width, height) = (px(480.), px(320.));
-
-            Some(
-                v_flex()
-                    .gap_6()
-                    .children(vec![
-                        example_group(vec![
-                            single_example(
-                                "No Repo",
-                                div()
-                                    .w(width)
-                                    .h(height)
-                                    .child(no_repo_state)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "No Changes",
-                                div()
-                                    .w(width)
-                                    .h(height)
-                                    .child(no_changes_state)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Unknown Upstream",
-                                div()
-                                    .w(width)
-                                    .h(height)
-                                    .child(unknown_upstream_state)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Ahead of Remote",
-                                div()
-                                    .w(width)
-                                    .h(height)
-                                    .child(ahead_of_upstream_state)
-                                    .into_any_element(),
-                            ),
-                        ])
-                        .vertical(),
-                    ])
-                    .into_any_element(),
-            )
-        }
-    }
-}
-
 struct BranchDiffAddon {
     branch_diff: Entity<branch_diff::BranchDiff>,
 }

crates/git_ui/src/text_diff_view.rs 🔗

@@ -165,7 +165,7 @@ impl TextDiffView {
         let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
 
         cx.subscribe(&source_buffer, move |this, _, event, _| match event {
-            language::BufferEvent::Edited
+            language::BufferEvent::Edited { .. }
             | language::BufferEvent::LanguageChanged(_)
             | language::BufferEvent::Reparsed => {
                 this.buffer_changes_tx.send(()).ok();

crates/go_to_line/Cargo.toml 🔗

@@ -34,6 +34,4 @@ menu.workspace = true
 project = { workspace = true, features = ["test-support"] }
 rope.workspace = true
 serde_json.workspace = true
-tree-sitter-rust.workspace = true
-tree-sitter-typescript.workspace = true
 workspace = { workspace = true, features = ["test-support"] }

crates/go_to_line/src/go_to_line.rs 🔗

@@ -94,7 +94,9 @@ impl GoToLine {
                 .read(cx)
                 .excerpts_for_buffer(snapshot.remote_id(), cx)
                 .into_iter()
-                .map(move |(_, range)| text::ToPoint::to_point(&range.context.end, &snapshot).row)
+                .map(move |(_, _, range)| {
+                    text::ToPoint::to_point(&range.context.end, &snapshot).row
+                })
                 .max()
                 .unwrap_or(0);
 

crates/gpui/Cargo.toml 🔗

@@ -24,6 +24,7 @@ test-support = [
     "http_client/test-support",
     "wayland",
     "x11",
+    "proptest",
 ]
 inspector = ["gpui_macros/inspector"]
 leak-detection = ["backtrace"]
@@ -64,6 +65,7 @@ num_cpus = "1.13"
 parking = "2.0.0"
 parking_lot.workspace = true
 postage.workspace = true
+proptest = { workspace = true, optional = true }
 chrono.workspace = true
 profiling.workspace = true
 rand.workspace = true
@@ -144,11 +146,11 @@ collections = { workspace = true, features = ["test-support"] }
 env_logger.workspace = true
 gpui_platform.workspace = true
 lyon = { version = "1.0", features = ["extra"] }
-pretty_assertions.workspace = true
 rand.workspace = true
 scheduler = { workspace = true, features = ["test-support"] }
 unicode-segmentation.workspace = true
 gpui_util = { workspace = true }
+proptest = { workspace = true }
 
 [target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
 http_client = { workspace = true, features = ["test-support"] }

crates/gpui/examples/active_state_bug.rs 🔗

@@ -0,0 +1,47 @@
+/// Click the button — the `.active()` background gets stuck on every other click.
+use gpui::*;
+use gpui_platform::application;
+
+struct Example;
+
+impl Render for Example {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        // Colors from Zed's default dark theme
+        let bg = hsla(215. / 360., 0.12, 0.15, 1.);
+        let text = hsla(221. / 360., 0.11, 0.86, 1.);
+        let hover = hsla(225. / 360., 0.118, 0.267, 1.);
+        let active = hsla(220. / 360., 0.118, 0.20, 1.);
+
+        div().bg(bg).size_full().p_1().child(
+            div()
+                .id("button")
+                .px_2()
+                .py_0p5()
+                .rounded_md()
+                .text_sm()
+                .text_color(text)
+                .hover(|s| s.bg(hover))
+                .active(|s| s.bg(active))
+                .on_click(|_, _, _| {})
+                .child("Click me"),
+        )
+    }
+}
+
+fn main() {
+    application().run(|cx: &mut App| {
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
+                    None,
+                    size(px(200.), px(60.)),
+                    cx,
+                ))),
+                ..Default::default()
+            },
+            |_, cx| cx.new(|_| Example),
+        )
+        .unwrap();
+        cx.activate(true);
+    });
+}

crates/gpui/src/app.rs 🔗

@@ -27,9 +27,13 @@ use collections::{FxHashMap, FxHashSet, HashMap, VecDeque};
 pub use context::*;
 pub use entity_map::*;
 use gpui_util::{ResultExt, debug_panic};
+#[cfg(any(test, feature = "test-support"))]
+pub use headless_app_context::*;
 use http_client::{HttpClient, Url};
 use smallvec::SmallVec;
 #[cfg(any(test, feature = "test-support"))]
+pub use test_app::*;
+#[cfg(any(test, feature = "test-support"))]
 pub use test_context::*;
 #[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
 pub use visual_test_context::*;
@@ -54,6 +58,10 @@ mod async_context;
 mod context;
 mod entity_map;
 #[cfg(any(test, feature = "test-support"))]
+mod headless_app_context;
+#[cfg(any(test, feature = "test-support"))]
+mod test_app;
+#[cfg(any(test, feature = "test-support"))]
 mod test_context;
 #[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
 mod visual_test_context;
@@ -744,9 +752,11 @@ impl App {
         }));
 
         platform.on_quit(Box::new({
-            let cx = app.clone();
+            let cx = Rc::downgrade(&app);
             move || {
-                cx.borrow_mut().shutdown();
+                if let Some(cx) = cx.upgrade() {
+                    cx.borrow_mut().shutdown();
+                }
             }
         }));
 
@@ -2613,13 +2623,6 @@ impl<'a, T> Drop for GpuiBorrow<'a, T> {
     }
 }
 
-impl Drop for App {
-    fn drop(&mut self) {
-        self.foreground_executor.close();
-        self.background_executor.close();
-    }
-}
-
 #[cfg(test)]
 mod test {
     use std::{cell::RefCell, rc::Rc};

crates/gpui/src/app/headless_app_context.rs 🔗

@@ -0,0 +1,275 @@
+//! Cross-platform headless app context for tests that need real text shaping.
+//!
+//! This replaces the macOS-only `HeadlessMetalAppContext` with a platform-neutral
+//! implementation backed by `TestPlatform`. Tests supply a real `PlatformTextSystem`
+//! (e.g. `DirectWriteTextSystem` on Windows, `MacTextSystem` on macOS) to get
+//! accurate glyph measurements while keeping everything else deterministic.
+//!
+//! Optionally, a renderer factory can be provided to enable real GPU rendering
+//! and screenshot capture via [`HeadlessAppContext::capture_screenshot`].
+
+use crate::{
+    AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, Bounds,
+    Context, Entity, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer,
+    PlatformTextSystem, Render, Reservation, Size, Task, TestDispatcher, TestPlatform, TextSystem,
+    Window, WindowBounds, WindowHandle, WindowOptions,
+    app::{GpuiBorrow, GpuiMode},
+};
+use anyhow::Result;
+use image::RgbaImage;
+use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
+
+/// A cross-platform headless app context for tests that need real text shaping.
+///
+/// Unlike the old `HeadlessMetalAppContext`, this works on any platform. It uses
+/// `TestPlatform` for deterministic scheduling and accepts a pluggable
+/// `PlatformTextSystem` so tests get real glyph measurements.
+///
+/// # Usage
+///
+/// ```ignore
+/// let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new("fallback"));
+/// let mut cx = HeadlessAppContext::with_platform(
+///     text_system,
+///     Arc::new(Assets),
+///     || gpui_platform::current_headless_renderer(),
+/// );
+/// ```
+pub struct HeadlessAppContext {
+    /// The underlying app cell.
+    pub app: Rc<AppCell>,
+    /// The background executor for running async tasks.
+    pub background_executor: BackgroundExecutor,
+    /// The foreground executor for running tasks on the main thread.
+    pub foreground_executor: ForegroundExecutor,
+    dispatcher: TestDispatcher,
+    text_system: Arc<TextSystem>,
+}
+
+impl HeadlessAppContext {
+    /// Creates a new headless app context with the given text system.
+    pub fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
+        Self::with_platform(platform_text_system, Arc::new(()), || None)
+    }
+
+    /// Creates a new headless app context with a custom text system and asset source.
+    pub fn with_asset_source(
+        platform_text_system: Arc<dyn PlatformTextSystem>,
+        asset_source: Arc<dyn AssetSource>,
+    ) -> Self {
+        Self::with_platform(platform_text_system, asset_source, || None)
+    }
+
+    /// Creates a new headless app context with the given text system, asset source,
+    /// and an optional renderer factory for screenshot support.
+    pub fn with_platform(
+        platform_text_system: Arc<dyn PlatformTextSystem>,
+        asset_source: Arc<dyn AssetSource>,
+        renderer_factory: impl Fn() -> Option<Box<dyn PlatformHeadlessRenderer>> + 'static,
+    ) -> Self {
+        let seed = std::env::var("SEED")
+            .ok()
+            .and_then(|s| s.parse().ok())
+            .unwrap_or(0);
+
+        let dispatcher = TestDispatcher::new(seed);
+        let arc_dispatcher = Arc::new(dispatcher.clone());
+        let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
+        let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
+
+        let renderer_factory: Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>> =
+            Box::new(renderer_factory);
+        let platform = TestPlatform::with_platform(
+            background_executor.clone(),
+            foreground_executor.clone(),
+            platform_text_system.clone(),
+            Some(renderer_factory),
+        );
+
+        let text_system = Arc::new(TextSystem::new(platform_text_system));
+        let http_client = http_client::FakeHttpClient::with_404_response();
+        let app = App::new_app(platform, asset_source, http_client);
+        app.borrow_mut().mode = GpuiMode::test();
+
+        Self {
+            app,
+            background_executor,
+            foreground_executor,
+            dispatcher,
+            text_system,
+        }
+    }
+
+    /// Opens a window for headless rendering.
+    pub fn open_window<V: Render + 'static>(
+        &mut self,
+        size: Size<Pixels>,
+        build_root: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
+    ) -> Result<WindowHandle<V>> {
+        use crate::{point, px};
+
+        let bounds = Bounds {
+            origin: point(px(0.0), px(0.0)),
+            size,
+        };
+
+        let mut cx = self.app.borrow_mut();
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                focus: false,
+                show: false,
+                ..Default::default()
+            },
+            build_root,
+        )
+    }
+
+    /// Runs all pending tasks until parked.
+    pub fn run_until_parked(&self) {
+        self.dispatcher.run_until_parked();
+    }
+
+    /// Advances the simulated clock.
+    pub fn advance_clock(&self, duration: Duration) {
+        self.dispatcher.advance_clock(duration);
+    }
+
+    /// Enables parking mode, allowing blocking on real I/O (e.g., async asset loading).
+    pub fn allow_parking(&self) {
+        self.dispatcher.allow_parking();
+    }
+
+    /// Disables parking mode, returning to deterministic test execution.
+    pub fn forbid_parking(&self) {
+        self.dispatcher.forbid_parking();
+    }
+
+    /// Updates app state.
+    pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
+        let mut app = self.app.borrow_mut();
+        f(&mut app)
+    }
+
+    /// Updates a window and calls draw to render.
+    pub fn update_window<R>(
+        &mut self,
+        window: AnyWindowHandle,
+        f: impl FnOnce(AnyView, &mut Window, &mut App) -> R,
+    ) -> Result<R> {
+        let mut app = self.app.borrow_mut();
+        app.update_window(window, f)
+    }
+
+    /// Captures a screenshot from a window.
+    ///
+    /// Requires that the context was created with a renderer factory that
+    /// returns `Some` via [`HeadlessAppContext::with_platform`].
+    pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result<RgbaImage> {
+        let mut app = self.app.borrow_mut();
+        app.update_window(window, |_, window, _| window.render_to_image())?
+    }
+
+    /// Returns the text system.
+    pub fn text_system(&self) -> &Arc<TextSystem> {
+        &self.text_system
+    }
+
+    /// Returns the background executor.
+    pub fn background_executor(&self) -> &BackgroundExecutor {
+        &self.background_executor
+    }
+
+    /// Returns the foreground executor.
+    pub fn foreground_executor(&self) -> &ForegroundExecutor {
+        &self.foreground_executor
+    }
+}
+
+impl Drop for HeadlessAppContext {
+    fn drop(&mut self) {
+        // Shut down the app so windows are closed and entity handles are
+        // released before the LeakDetector runs.
+        self.app.borrow_mut().shutdown();
+    }
+}
+
+impl AppContext for HeadlessAppContext {
+    fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
+        let mut app = self.app.borrow_mut();
+        app.new(build_entity)
+    }
+
+    fn reserve_entity<T: 'static>(&mut self) -> Reservation<T> {
+        let mut app = self.app.borrow_mut();
+        app.reserve_entity()
+    }
+
+    fn insert_entity<T: 'static>(
+        &mut self,
+        reservation: Reservation<T>,
+        build_entity: impl FnOnce(&mut Context<T>) -> T,
+    ) -> Entity<T> {
+        let mut app = self.app.borrow_mut();
+        app.insert_entity(reservation, build_entity)
+    }
+
+    fn update_entity<T: 'static, R>(
+        &mut self,
+        handle: &Entity<T>,
+        update: impl FnOnce(&mut T, &mut Context<T>) -> R,
+    ) -> R {
+        let mut app = self.app.borrow_mut();
+        app.update_entity(handle, update)
+    }
+
+    fn as_mut<'a, T>(&'a mut self, _: &Entity<T>) -> GpuiBorrow<'a, T>
+    where
+        T: 'static,
+    {
+        panic!("Cannot use as_mut with HeadlessAppContext. Call update() instead.")
+    }
+
+    fn read_entity<T, R>(&self, handle: &Entity<T>, read: impl FnOnce(&T, &App) -> R) -> R
+    where
+        T: 'static,
+    {
+        let app = self.app.borrow();
+        app.read_entity(handle, read)
+    }
+
+    fn update_window<T, F>(&mut self, window: AnyWindowHandle, f: F) -> Result<T>
+    where
+        F: FnOnce(AnyView, &mut Window, &mut App) -> T,
+    {
+        let mut lock = self.app.borrow_mut();
+        lock.update_window(window, f)
+    }
+
+    fn read_window<T, R>(
+        &self,
+        window: &WindowHandle<T>,
+        read: impl FnOnce(Entity<T>, &App) -> R,
+    ) -> Result<R>
+    where
+        T: 'static,
+    {
+        let app = self.app.borrow();
+        app.read_window(window, read)
+    }
+
+    fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
+    where
+        R: Send + 'static,
+    {
+        self.background_executor.spawn(future)
+    }
+
+    fn read_global<G, R>(&self, callback: impl FnOnce(&G, &App) -> R) -> R
+    where
+        G: Global,
+    {
+        let app = self.app.borrow();
+        app.read_global(callback)
+    }
+}

crates/gpui/src/app/test_app.rs 🔗

@@ -0,0 +1,607 @@
+//! A clean testing API for GPUI applications.
+//!
+//! `TestApp` provides a simpler alternative to `TestAppContext` with:
+//! - Automatic effect flushing after updates
+//! - Clean window creation and inspection
+//! - Input simulation helpers
+//!
+//! # Example
+//! ```ignore
+//! #[test]
+//! fn test_my_view() {
+//!     let mut app = TestApp::new();
+//!
+//!     let mut window = app.open_window(|window, cx| {
+//!         MyView::new(window, cx)
+//!     });
+//!
+//!     window.update(|view, window, cx| {
+//!         view.do_something(cx);
+//!     });
+//!
+//!     // Check rendered state
+//!     assert_eq!(window.title(), Some("Expected Title"));
+//! }
+//! ```
+
+use crate::{
+    AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext,
+    Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke,
+    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform,
+    PlatformTextSystem, Point, Render, Size, Task, TestDispatcher, TestPlatform, TextSystem,
+    Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode,
+};
+use std::{future::Future, rc::Rc, sync::Arc, time::Duration};
+
+/// A test application context with a clean API.
+///
+/// Unlike `TestAppContext`, `TestApp` automatically flushes effects after
+/// each update and provides simpler window management.
+pub struct TestApp {
+    app: Rc<AppCell>,
+    platform: Rc<TestPlatform>,
+    background_executor: BackgroundExecutor,
+    foreground_executor: ForegroundExecutor,
+    #[allow(dead_code)]
+    dispatcher: TestDispatcher,
+    text_system: Arc<TextSystem>,
+}
+
+impl TestApp {
+    /// Create a new test application.
+    pub fn new() -> Self {
+        Self::with_seed(0)
+    }
+
+    /// Create a new test application with a specific random seed.
+    pub fn with_seed(seed: u64) -> Self {
+        Self::build(seed, None, Arc::new(()))
+    }
+
+    /// Create a new test application with a custom text system for real font shaping.
+    pub fn with_text_system(text_system: Arc<dyn PlatformTextSystem>) -> Self {
+        Self::build(0, Some(text_system), Arc::new(()))
+    }
+
+    /// Create a new test application with a custom text system and asset source.
+    pub fn with_text_system_and_assets(
+        text_system: Arc<dyn PlatformTextSystem>,
+        asset_source: Arc<dyn crate::AssetSource>,
+    ) -> Self {
+        Self::build(0, Some(text_system), asset_source)
+    }
+
+    fn build(
+        seed: u64,
+        platform_text_system: Option<Arc<dyn PlatformTextSystem>>,
+        asset_source: Arc<dyn crate::AssetSource>,
+    ) -> Self {
+        let dispatcher = TestDispatcher::new(seed);
+        let arc_dispatcher = Arc::new(dispatcher.clone());
+        let background_executor = BackgroundExecutor::new(arc_dispatcher.clone());
+        let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
+        let platform = match platform_text_system.clone() {
+            Some(ts) => TestPlatform::with_text_system(
+                background_executor.clone(),
+                foreground_executor.clone(),
+                ts,
+            ),
+            None => TestPlatform::new(background_executor.clone(), foreground_executor.clone()),
+        };
+        let http_client = http_client::FakeHttpClient::with_404_response();
+        let text_system = Arc::new(TextSystem::new(
+            platform_text_system.unwrap_or_else(|| platform.text_system.clone()),
+        ));
+
+        let app = App::new_app(platform.clone(), asset_source, http_client);
+        app.borrow_mut().mode = GpuiMode::test();
+
+        Self {
+            app,
+            platform,
+            background_executor,
+            foreground_executor,
+            dispatcher,
+            text_system,
+        }
+    }
+
+    /// Run a closure with mutable access to the App context.
+    /// Automatically runs until parked after the closure completes.
+    pub fn update<R>(&mut self, f: impl FnOnce(&mut App) -> R) -> R {
+        let result = {
+            let mut app = self.app.borrow_mut();
+            app.update(f)
+        };
+        self.run_until_parked();
+        result
+    }
+
+    /// Run a closure with read-only access to the App context.
+    pub fn read<R>(&self, f: impl FnOnce(&App) -> R) -> R {
+        let app = self.app.borrow();
+        f(&app)
+    }
+
+    /// Create a new entity in the app.
+    pub fn new_entity<T: 'static>(
+        &mut self,
+        build: impl FnOnce(&mut Context<T>) -> T,
+    ) -> Entity<T> {
+        self.update(|cx| cx.new(build))
+    }
+
+    /// Update an entity.
+    pub fn update_entity<T: 'static, R>(
+        &mut self,
+        entity: &Entity<T>,
+        f: impl FnOnce(&mut T, &mut Context<T>) -> R,
+    ) -> R {
+        self.update(|cx| entity.update(cx, f))
+    }
+
+    /// Read an entity.
+    pub fn read_entity<T: 'static, R>(
+        &self,
+        entity: &Entity<T>,
+        f: impl FnOnce(&T, &App) -> R,
+    ) -> R {
+        self.read(|cx| f(entity.read(cx), cx))
+    }
+
+    /// Open a test window with the given root view, using maximized bounds.
+    pub fn open_window<V: Render + 'static>(
+        &mut self,
+        build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
+    ) -> TestAppWindow<V> {
+        let bounds = self.read(|cx| Bounds::maximized(None, cx));
+        let handle = self.update(|cx| {
+            cx.open_window(
+                WindowOptions {
+                    window_bounds: Some(WindowBounds::Windowed(bounds)),
+                    ..Default::default()
+                },
+                |window, cx| cx.new(|cx| build_view(window, cx)),
+            )
+            .unwrap()
+        });
+
+        TestAppWindow {
+            handle,
+            app: self.app.clone(),
+            platform: self.platform.clone(),
+            background_executor: self.background_executor.clone(),
+        }
+    }
+
+    /// Open a test window with specific options.
+    pub fn open_window_with_options<V: Render + 'static>(
+        &mut self,
+        options: WindowOptions,
+        build_view: impl FnOnce(&mut Window, &mut Context<V>) -> V,
+    ) -> TestAppWindow<V> {
+        let handle = self.update(|cx| {
+            cx.open_window(options, |window, cx| cx.new(|cx| build_view(window, cx)))
+                .unwrap()
+        });
+
+        TestAppWindow {
+            handle,
+            app: self.app.clone(),
+            platform: self.platform.clone(),
+            background_executor: self.background_executor.clone(),
+        }
+    }
+
+    /// Run pending tasks until there's nothing left to do.
+    pub fn run_until_parked(&self) {
+        self.background_executor.run_until_parked();
+    }
+
+    /// Advance the simulated clock by the given duration.
+    pub fn advance_clock(&self, duration: Duration) {
+        self.background_executor.advance_clock(duration);
+    }
+
+    /// Spawn a future on the foreground executor.
+    pub fn spawn<Fut, R>(&self, f: impl FnOnce(AsyncApp) -> Fut) -> Task<R>
+    where
+        Fut: Future<Output = R> + 'static,
+        R: 'static,
+    {
+        self.foreground_executor.spawn(f(self.to_async()))
+    }
+
+    /// Spawn a future on the background executor.
+    pub fn background_spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
+    where
+        R: Send + 'static,
+    {
+        self.background_executor.spawn(future)
+    }
+
+    /// Get an async handle to the app.
+    pub fn to_async(&self) -> AsyncApp {
+        AsyncApp {
+            app: Rc::downgrade(&self.app),
+            background_executor: self.background_executor.clone(),
+            foreground_executor: self.foreground_executor.clone(),
+        }
+    }
+
+    /// Get the background executor.
+    pub fn background_executor(&self) -> &BackgroundExecutor {
+        &self.background_executor
+    }
+
+    /// Get the foreground executor.
+    pub fn foreground_executor(&self) -> &ForegroundExecutor {
+        &self.foreground_executor
+    }
+
+    /// Get the text system.
+    pub fn text_system(&self) -> &Arc<TextSystem> {
+        &self.text_system
+    }
+
+    /// Check if a global of the given type exists.
+    pub fn has_global<G: Global>(&self) -> bool {
+        self.read(|cx| cx.has_global::<G>())
+    }
+
+    /// Set a global value.
+    pub fn set_global<G: Global>(&mut self, global: G) {
+        self.update(|cx| cx.set_global(global));
+    }
+
+    /// Read a global value.
+    pub fn read_global<G: Global, R>(&self, f: impl FnOnce(&G, &App) -> R) -> R {
+        self.read(|cx| f(cx.global(), cx))
+    }
+
+    /// Update a global value.
+    pub fn update_global<G: Global, R>(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R {
+        self.update(|cx| cx.update_global(f))
+    }
+
+    // Platform simulation methods
+
+    /// Write text to the simulated clipboard.
+    pub fn write_to_clipboard(&self, item: ClipboardItem) {
+        self.platform.write_to_clipboard(item);
+    }
+
+    /// Read from the simulated clipboard.
+    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_clipboard()
+    }
+
+    /// Get URLs that have been opened via `cx.open_url()`.
+    pub fn opened_url(&self) -> Option<String> {
+        self.platform.opened_url.borrow().clone()
+    }
+
+    /// Check if a file path prompt is pending.
+    pub fn did_prompt_for_new_path(&self) -> bool {
+        self.platform.did_prompt_for_new_path()
+    }
+
+    /// Simulate answering a path selection dialog.
+    pub fn simulate_new_path_selection(
+        &self,
+        select: impl FnOnce(&std::path::Path) -> Option<std::path::PathBuf>,
+    ) {
+        self.platform.simulate_new_path_selection(select);
+    }
+
+    /// Check if a prompt dialog is pending.
+    pub fn has_pending_prompt(&self) -> bool {
+        self.platform.has_pending_prompt()
+    }
+
+    /// Simulate answering a prompt dialog.
+    pub fn simulate_prompt_answer(&self, button: &str) {
+        self.platform.simulate_prompt_answer(button);
+    }
+
+    /// Get all open windows.
+    pub fn windows(&self) -> Vec<AnyWindowHandle> {
+        self.read(|cx| cx.windows())
+    }
+}
+
+impl Default for TestApp {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+/// A test window with inspection and simulation capabilities.
+pub struct TestAppWindow<V> {
+    handle: WindowHandle<V>,
+    app: Rc<AppCell>,
+    platform: Rc<TestPlatform>,
+    background_executor: BackgroundExecutor,
+}
+
+impl<V: 'static + Render> TestAppWindow<V> {
+    /// Get the window handle.
+    pub fn handle(&self) -> WindowHandle<V> {
+        self.handle
+    }
+
+    /// Get the root view entity.
+    pub fn root(&self) -> Entity<V> {
+        let mut app = self.app.borrow_mut();
+        let any_handle: AnyWindowHandle = self.handle.into();
+        app.update_window(any_handle, |root_view, _, _| {
+            root_view.downcast::<V>().expect("root view type mismatch")
+        })
+        .expect("window not found")
+    }
+
+    /// Update the root view.
+    pub fn update<R>(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context<V>) -> R) -> R {
+        let result = {
+            let mut app = self.app.borrow_mut();
+            let any_handle: AnyWindowHandle = self.handle.into();
+            app.update_window(any_handle, |root_view, window, cx| {
+                let view = root_view.downcast::<V>().expect("root view type mismatch");
+                view.update(cx, |view, cx| f(view, window, cx))
+            })
+            .expect("window not found")
+        };
+        self.background_executor.run_until_parked();
+        result
+    }
+
+    /// Read the root view.
+    pub fn read<R>(&self, f: impl FnOnce(&V, &App) -> R) -> R {
+        let app = self.app.borrow();
+        let view = self
+            .app
+            .borrow()
+            .windows
+            .get(self.handle.window_id())
+            .and_then(|w| w.as_ref())
+            .and_then(|w| w.root.clone())
+            .and_then(|r| r.downcast::<V>().ok())
+            .expect("window or root view not found");
+        f(view.read(&app), &app)
+    }
+
+    /// Get the window title.
+    pub fn title(&self) -> Option<String> {
+        let app = self.app.borrow();
+        app.read_window(&self.handle, |_, _cx| {
+            // TODO: expose title through Window API
+            None
+        })
+        .unwrap()
+    }
+
+    /// Simulate a keystroke.
+    pub fn simulate_keystroke(&mut self, keystroke: &str) {
+        let keystroke = Keystroke::parse(keystroke).unwrap();
+        {
+            let mut app = self.app.borrow_mut();
+            let any_handle: AnyWindowHandle = self.handle.into();
+            app.update_window(any_handle, |_, window, cx| {
+                window.dispatch_keystroke(keystroke, cx);
+            })
+            .unwrap();
+        }
+        self.background_executor.run_until_parked();
+    }
+
+    /// Simulate multiple keystrokes (space-separated).
+    pub fn simulate_keystrokes(&mut self, keystrokes: &str) {
+        for keystroke in keystrokes.split(' ') {
+            self.simulate_keystroke(keystroke);
+        }
+    }
+
+    /// Simulate typing text.
+    pub fn simulate_input(&mut self, input: &str) {
+        for char in input.chars() {
+            self.simulate_keystroke(&char.to_string());
+        }
+    }
+
+    /// Simulate a mouse move.
+    pub fn simulate_mouse_move(&mut self, position: Point<Pixels>) {
+        self.simulate_event(MouseMoveEvent {
+            position,
+            modifiers: Default::default(),
+            pressed_button: None,
+        });
+    }
+
+    /// Simulate a mouse down event.
+    pub fn simulate_mouse_down(&mut self, position: Point<Pixels>, button: MouseButton) {
+        self.simulate_event(MouseDownEvent {
+            position,
+            button,
+            modifiers: Default::default(),
+            click_count: 1,
+            first_mouse: false,
+        });
+    }
+
+    /// Simulate a mouse up event.
+    pub fn simulate_mouse_up(&mut self, position: Point<Pixels>, button: MouseButton) {
+        self.simulate_event(MouseUpEvent {
+            position,
+            button,
+            modifiers: Default::default(),
+            click_count: 1,
+        });
+    }
+
+    /// Simulate a click at the given position.
+    pub fn simulate_click(&mut self, position: Point<Pixels>, button: MouseButton) {
+        self.simulate_mouse_down(position, button);
+        self.simulate_mouse_up(position, button);
+    }
+
+    /// Simulate a scroll event.
+    pub fn simulate_scroll(&mut self, position: Point<Pixels>, delta: Point<Pixels>) {
+        self.simulate_event(crate::ScrollWheelEvent {
+            position,
+            delta: crate::ScrollDelta::Pixels(delta),
+            modifiers: Default::default(),
+            touch_phase: crate::TouchPhase::Moved,
+        });
+    }
+
+    /// Simulate an input event.
+    pub fn simulate_event<E: InputEvent>(&mut self, event: E) {
+        let platform_input = event.to_platform_input();
+        {
+            let mut app = self.app.borrow_mut();
+            let any_handle: AnyWindowHandle = self.handle.into();
+            app.update_window(any_handle, |_, window, cx| {
+                window.dispatch_event(platform_input, cx);
+            })
+            .unwrap();
+        }
+        self.background_executor.run_until_parked();
+    }
+
+    /// Simulate resizing the window.
+    pub fn simulate_resize(&mut self, size: Size<Pixels>) {
+        let window_id = self.handle.window_id();
+        let mut app = self.app.borrow_mut();
+        if let Some(Some(window)) = app.windows.get_mut(window_id) {
+            if let Some(test_window) = window.platform_window.as_test() {
+                test_window.simulate_resize(size);
+            }
+        }
+        drop(app);
+        self.background_executor.run_until_parked();
+    }
+
+    /// Force a redraw of the window.
+    pub fn draw(&mut self) {
+        let mut app = self.app.borrow_mut();
+        let any_handle: AnyWindowHandle = self.handle.into();
+        app.update_window(any_handle, |_, window, cx| {
+            window.draw(cx).clear();
+        })
+        .unwrap();
+    }
+}
+
+impl<V> Clone for TestAppWindow<V> {
+    fn clone(&self) -> Self {
+        Self {
+            handle: self.handle,
+            app: self.app.clone(),
+            platform: self.platform.clone(),
+            background_executor: self.background_executor.clone(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{FocusHandle, Focusable, div, prelude::*};
+
+    struct Counter {
+        count: usize,
+        focus_handle: FocusHandle,
+    }
+
+    impl Counter {
+        fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
+            let focus_handle = cx.focus_handle();
+            Self {
+                count: 0,
+                focus_handle,
+            }
+        }
+
+        fn increment(&mut self, _cx: &mut Context<Self>) {
+            self.count += 1;
+        }
+    }
+
+    impl Focusable for Counter {
+        fn focus_handle(&self, _cx: &App) -> FocusHandle {
+            self.focus_handle.clone()
+        }
+    }
+
+    impl Render for Counter {
+        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+            div().child(format!("Count: {}", self.count))
+        }
+    }
+
+    #[test]
+    fn test_basic_usage() {
+        let mut app = TestApp::new();
+
+        let mut window = app.open_window(Counter::new);
+
+        window.update(|counter, _window, cx| {
+            counter.increment(cx);
+        });
+
+        window.read(|counter, _| {
+            assert_eq!(counter.count, 1);
+        });
+
+        drop(window);
+        app.update(|cx| cx.shutdown());
+    }
+
+    #[test]
+    fn test_entity_creation() {
+        let mut app = TestApp::new();
+
+        let entity = app.new_entity(|cx| Counter {
+            count: 42,
+            focus_handle: cx.focus_handle(),
+        });
+
+        app.read_entity(&entity, |counter, _| {
+            assert_eq!(counter.count, 42);
+        });
+
+        app.update_entity(&entity, |counter, _cx| {
+            counter.count += 1;
+        });
+
+        app.read_entity(&entity, |counter, _| {
+            assert_eq!(counter.count, 43);
+        });
+    }
+
+    #[test]
+    fn test_globals() {
+        let mut app = TestApp::new();
+
+        struct MyGlobal(String);
+        impl Global for MyGlobal {}
+
+        assert!(!app.has_global::<MyGlobal>());
+
+        app.set_global(MyGlobal("hello".into()));
+
+        assert!(app.has_global::<MyGlobal>());
+
+        app.read_global::<MyGlobal, _>(|global, _| {
+            assert_eq!(global.0, "hello");
+        });
+
+        app.update_global::<MyGlobal, _>(|global, _| {
+            global.0 = "world".into();
+        });
+
+        app.read_global::<MyGlobal, _>(|global, _| {
+            assert_eq!(global.0, "world");
+        });
+    }
+}

crates/gpui/src/app/test_context.rs 🔗

@@ -22,7 +22,8 @@ pub struct TestAppContext {
     pub background_executor: BackgroundExecutor,
     #[doc(hidden)]
     pub foreground_executor: ForegroundExecutor,
-    dispatcher: TestDispatcher,
+    #[doc(hidden)]
+    pub dispatcher: TestDispatcher,
     test_platform: Rc<TestPlatform>,
     text_system: Arc<TextSystem>,
     fn_name: Option<&'static str>,
@@ -231,6 +232,33 @@ impl TestAppContext {
         .unwrap()
     }
 
+    /// Opens a new window with a specific size.
+    ///
+    /// Unlike `add_window` which uses maximized bounds, this allows controlling
+    /// the window dimensions, which is important for layout-sensitive tests.
+    pub fn open_window<F, V>(
+        &mut self,
+        window_size: Size<Pixels>,
+        build_window: F,
+    ) -> WindowHandle<V>
+    where
+        F: FnOnce(&mut Window, &mut Context<V>) -> V,
+        V: 'static + Render,
+    {
+        let mut cx = self.app.borrow_mut();
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(Bounds {
+                    origin: Point::default(),
+                    size: window_size,
+                })),
+                ..Default::default()
+            },
+            |window, cx| cx.new(|cx| build_window(window, cx)),
+        )
+        .unwrap()
+    }
+
     /// Adds a new window with no content.
     pub fn add_empty_window(&mut self) -> &mut VisualTestContext {
         let mut cx = self.app.borrow_mut();

crates/gpui/src/color.rs 🔗

@@ -820,6 +820,15 @@ impl LinearColorStop {
 }
 
 impl Background {
+    /// Returns the solid color if this is a solid background, None otherwise.
+    pub fn as_solid(&self) -> Option<Hsla> {
+        if self.tag == BackgroundTag::Solid {
+            Some(self.solid)
+        } else {
+            None
+        }
+    }
+
     /// Use specified color space for color interpolation.
     ///
     /// <https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method>

crates/gpui/src/elements/div.rs 🔗

@@ -15,6 +15,8 @@
 //! and Tailwind-like styling that you can use to build your own custom elements. Div is
 //! constructed by combining these two systems into an all-in-one element.
 
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use crate::PinchEvent;
 use crate::{
     AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent,
     DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId,
@@ -353,6 +355,43 @@ impl Interactivity {
             }));
     }
 
+    /// Bind the given callback to pinch gesture events during the bubble phase.
+    ///
+    /// Note: This event is only available on macOS and Wayland (Linux).
+    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) {
+        self.pinch_listeners
+            .push(Box::new(move |event, phase, hitbox, window, cx| {
+                if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
+                    (listener)(event, window, cx);
+                }
+            }));
+    }
+
+    /// Bind the given callback to pinch gesture events during the capture phase.
+    ///
+    /// Note: This event is only available on macOS and Wayland (Linux).
+    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    pub fn capture_pinch(
+        &mut self,
+        listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static,
+    ) {
+        self.pinch_listeners
+            .push(Box::new(move |event, phase, _hitbox, window, cx| {
+                if phase == DispatchPhase::Capture {
+                    (listener)(event, window, cx);
+                } else {
+                    cx.propagate();
+                }
+            }));
+    }
+
     /// Bind the given callback to an action dispatch during the capture phase.
     /// The imperative API equivalent to [`InteractiveElement::capture_action`].
     ///
@@ -635,6 +674,16 @@ impl Interactivity {
     pub fn block_mouse_except_scroll(&mut self) {
         self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll;
     }
+
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    fn has_pinch_listeners(&self) -> bool {
+        !self.pinch_listeners.is_empty()
+    }
+
+    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+    fn has_pinch_listeners(&self) -> bool {
+        false
+    }
 }
 
 /// A trait for elements that want to use the standard GPUI event handlers that don't
@@ -905,6 +954,34 @@ pub trait InteractiveElement: Sized {
         self
     }
 
+    /// Bind the given callback to pinch gesture events during the bubble phase.
+    /// The fluent API equivalent to [`Interactivity::on_pinch`].
+    ///
+    /// Note: This event is only available on macOS and Wayland (Linux).
+    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self {
+        self.interactivity().on_pinch(listener);
+        self
+    }
+
+    /// Bind the given callback to pinch gesture events during the capture phase.
+    /// The fluent API equivalent to [`Interactivity::capture_pinch`].
+    ///
+    /// Note: This event is only available on macOS and Wayland (Linux).
+    /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    fn capture_pinch(
+        mut self,
+        listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.interactivity().capture_pinch(listener);
+        self
+    }
     /// Capture the given action, before normal action dispatch can fire.
     /// The fluent API equivalent to [`Interactivity::capture_action`].
     ///
@@ -1290,6 +1367,10 @@ pub(crate) type MouseMoveListener =
 pub(crate) type ScrollWheelListener =
     Box<dyn Fn(&ScrollWheelEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) type PinchListener =
+    Box<dyn Fn(&PinchEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
+
 pub(crate) type ClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
 
 pub(crate) type DragListener =
@@ -1644,6 +1725,8 @@ pub struct Interactivity {
     pub(crate) mouse_pressure_listeners: Vec<MousePressureListener>,
     pub(crate) mouse_move_listeners: Vec<MouseMoveListener>,
     pub(crate) scroll_wheel_listeners: Vec<ScrollWheelListener>,
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    pub(crate) pinch_listeners: Vec<PinchListener>,
     pub(crate) key_down_listeners: Vec<KeyDownListener>,
     pub(crate) key_up_listeners: Vec<KeyUpListener>,
     pub(crate) modifiers_changed_listeners: Vec<ModifiersChangedListener>,
@@ -1847,6 +1930,7 @@ impl Interactivity {
             || !self.click_listeners.is_empty()
             || !self.aux_click_listeners.is_empty()
             || !self.scroll_wheel_listeners.is_empty()
+            || self.has_pinch_listeners()
             || self.drag_listener.is_some()
             || !self.drop_listeners.is_empty()
             || self.tooltip_builder.is_some()
@@ -2213,6 +2297,14 @@ impl Interactivity {
             })
         }
 
+        #[cfg(any(target_os = "linux", target_os = "macos"))]
+        for listener in self.pinch_listeners.drain(..) {
+            let hitbox = hitbox.clone();
+            window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| {
+                listener(event, phase, &hitbox, window, cx);
+            })
+        }
+
         if self.hover_style.is_some()
             || self.base_style.mouse_cursor.is_some()
             || cx.active_drag.is_some() && !self.drag_over_styles.is_empty()
@@ -2517,18 +2609,24 @@ impl Interactivity {
                 );
             }
 
+            // We unconditionally bind both the mouse up and mouse down active state handlers
+            // Because we might not get a chance to render a frame before the mouse up event arrives.
             let active_state = element_state
                 .clicked_state
                 .get_or_insert_with(Default::default)
                 .clone();
-            if active_state.borrow().is_clicked() {
+
+            {
+                let active_state = active_state.clone();
                 window.on_mouse_event(move |_: &MouseUpEvent, phase, window, _cx| {
-                    if phase == DispatchPhase::Capture {
+                    if phase == DispatchPhase::Capture && active_state.borrow().is_clicked() {
                         *active_state.borrow_mut() = ElementClickedState::default();
                         window.refresh();
                     }
                 });
-            } else {
+            }
+
+            {
                 let active_group_hitbox = self
                     .group_active_style
                     .as_ref()

crates/gpui/src/elements/list.rs 🔗

@@ -1103,6 +1103,7 @@ impl Element for List {
             );
 
             state.items = new_items;
+            state.measuring_behavior.reset();
         }
 
         let padding = style
@@ -1348,6 +1349,41 @@ mod test {
         assert_eq!(offset.offset_in_item, px(0.));
     }
 
+    #[gpui::test]
+    fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(50.)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+        // First draw at width 100: all 10 items measured (total 500px).
+        // Viewport is 200px, so max scroll offset should be 300px.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
+
+        // Second draw at a different width: items get invalidated.
+        // Without the fix, max_offset would drop because unmeasured items
+        // contribute 0 height.
+        cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
+            view.into_any_element()
+        });
+        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
+    }
+
     #[gpui::test]
     fn test_remeasure(cx: &mut TestAppContext) {
         let cx = cx.add_empty_window();

crates/gpui/src/elements/text.rs 🔗

@@ -246,7 +246,12 @@ impl StyledText {
     pub fn with_runs(mut self, runs: Vec<TextRun>) -> Self {
         let mut text = &**self.text;
         for run in &runs {
-            text = text.get(run.len..).expect("invalid text run");
+            text = text.get(run.len..).unwrap_or_else(|| {
+                #[cfg(debug_assertions)]
+                panic!("invalid text run. Text: '{text}', run: {run:?}");
+                #[cfg(not(debug_assertions))]
+                panic!("invalid text run");
+            });
         }
         assert!(text.is_empty(), "invalid text run");
         self.runs = Some(runs);

crates/gpui/src/executor.rs 🔗

@@ -129,9 +129,11 @@ impl BackgroundExecutor {
         }
     }
 
-    /// Close this executor. Tasks will not run after this is called.
-    pub fn close(&self) {
-        self.inner.close();
+    /// Returns the underlying scheduler::BackgroundExecutor.
+    ///
+    /// This is used by Ex to pass the executor to thread/worktree code.
+    pub fn scheduler_executor(&self) -> scheduler::BackgroundExecutor {
+        self.inner.clone()
     }
 
     /// Enqueues the given future to be run to completion on a background thread.
@@ -173,7 +175,6 @@ impl BackgroundExecutor {
     {
         use crate::RunnableMeta;
         use parking_lot::{Condvar, Mutex};
-        use std::sync::{Arc, atomic::AtomicBool};
 
         struct NotifyOnDrop<'a>(&'a (Condvar, Mutex<bool>));
 
@@ -197,14 +198,13 @@ impl BackgroundExecutor {
 
         let dispatcher = self.dispatcher.clone();
         let location = core::panic::Location::caller();
-        let closed = Arc::new(AtomicBool::new(false));
 
         let pair = &(Condvar::new(), Mutex::new(false));
         let _wait_guard = WaitOnDrop(pair);
 
         let (runnable, task) = unsafe {
             async_task::Builder::new()
-                .metadata(RunnableMeta { location, closed })
+                .metadata(RunnableMeta { location })
                 .spawn_unchecked(
                     move |_| async {
                         let _notify_guard = NotifyOnDrop(pair);
@@ -404,11 +404,6 @@ impl ForegroundExecutor {
         }
     }
 
-    /// Close this executor. Tasks will not run after this is called.
-    pub fn close(&self) {
-        self.inner.close();
-    }
-
     /// Enqueues the given Task to run on the main thread.
     #[track_caller]
     pub fn spawn<R>(&self, future: impl Future<Output = R> + 'static) -> Task<R>
@@ -595,144 +590,4 @@ mod test {
             "Task should run normally when app is alive"
         );
     }
-
-    #[test]
-    fn test_task_cancelled_when_app_dropped() {
-        let (dispatcher, _background_executor, app) = create_test_app();
-        let foreground_executor = app.borrow().foreground_executor.clone();
-        let app_weak = Rc::downgrade(&app);
-
-        let task_ran = Rc::new(RefCell::new(false));
-        let task_ran_clone = Rc::clone(&task_ran);
-
-        foreground_executor
-            .spawn(async move {
-                *task_ran_clone.borrow_mut() = true;
-            })
-            .detach();
-
-        drop(app);
-
-        assert!(app_weak.upgrade().is_none(), "App should have been dropped");
-
-        dispatcher.run_until_parked();
-
-        // The task should have been cancelled, not run
-        assert!(
-            !*task_ran.borrow(),
-            "Task should have been cancelled when app was dropped, but it ran!"
-        );
-    }
-
-    #[test]
-    fn test_nested_tasks_both_cancel() {
-        let (dispatcher, _background_executor, app) = create_test_app();
-        let foreground_executor = app.borrow().foreground_executor.clone();
-        let app_weak = Rc::downgrade(&app);
-
-        let outer_completed = Rc::new(RefCell::new(false));
-        let inner_completed = Rc::new(RefCell::new(false));
-        let reached_await = Rc::new(RefCell::new(false));
-
-        let outer_flag = Rc::clone(&outer_completed);
-        let inner_flag = Rc::clone(&inner_completed);
-        let await_flag = Rc::clone(&reached_await);
-
-        // Channel to block the inner task until we're ready
-        let (tx, rx) = futures::channel::oneshot::channel::<()>();
-
-        let inner_executor = foreground_executor.clone();
-
-        foreground_executor
-            .spawn(async move {
-                let inner_task = inner_executor.spawn({
-                    let inner_flag = Rc::clone(&inner_flag);
-                    async move {
-                        rx.await.ok();
-                        *inner_flag.borrow_mut() = true;
-                    }
-                });
-
-                *await_flag.borrow_mut() = true;
-
-                inner_task.await;
-
-                *outer_flag.borrow_mut() = true;
-            })
-            .detach();
-
-        // Run dispatcher until outer task reaches the await point
-        // The inner task will be blocked on the channel
-        dispatcher.run_until_parked();
-
-        // Verify we actually reached the await point before dropping the app
-        assert!(
-            *reached_await.borrow(),
-            "Outer task should have reached the await point"
-        );
-
-        // Neither task should have completed yet
-        assert!(
-            !*outer_completed.borrow(),
-            "Outer task should not have completed yet"
-        );
-        assert!(
-            !*inner_completed.borrow(),
-            "Inner task should not have completed yet"
-        );
-
-        // Drop the channel sender and app while outer is awaiting inner
-        drop(tx);
-        drop(app);
-        assert!(app_weak.upgrade().is_none(), "App should have been dropped");
-
-        // Run dispatcher - both tasks should be cancelled
-        dispatcher.run_until_parked();
-
-        // Neither task should have completed (both were cancelled)
-        assert!(
-            !*outer_completed.borrow(),
-            "Outer task should have been cancelled, not completed"
-        );
-        assert!(
-            !*inner_completed.borrow(),
-            "Inner task should have been cancelled, not completed"
-        );
-    }
-
-    #[test]
-    #[should_panic]
-    fn test_polling_cancelled_task_panics() {
-        let (dispatcher, _background_executor, app) = create_test_app();
-        let foreground_executor = app.borrow().foreground_executor.clone();
-        let app_weak = Rc::downgrade(&app);
-
-        let task = foreground_executor.spawn(async move { 42 });
-
-        drop(app);
-
-        assert!(app_weak.upgrade().is_none(), "App should have been dropped");
-
-        dispatcher.run_until_parked();
-
-        foreground_executor.block_on(task);
-    }
-
-    #[test]
-    fn test_polling_cancelled_task_returns_none_with_fallible() {
-        let (dispatcher, _background_executor, app) = create_test_app();
-        let foreground_executor = app.borrow().foreground_executor.clone();
-        let app_weak = Rc::downgrade(&app);
-
-        let task = foreground_executor.spawn(async move { 42 }).fallible();
-
-        drop(app);
-
-        assert!(app_weak.upgrade().is_none(), "App should have been dropped");
-
-        dispatcher.run_until_parked();
-
-        let result = foreground_executor.block_on(task);
-        assert_eq!(result, None, "Cancelled task should return None");
-    }
 }

crates/gpui/src/gpui.rs 🔗

@@ -54,6 +54,9 @@ mod util;
 mod view;
 mod window;
 
+#[cfg(any(test, feature = "test-support"))]
+pub use proptest;
+
 #[cfg(doc)]
 pub mod _ownership_and_data_flow;
 
@@ -86,7 +89,9 @@ pub use elements::*;
 pub use executor::*;
 pub use geometry::*;
 pub use global::*;
-pub use gpui_macros::{AppContext, IntoElement, Render, VisualContext, register_action, test};
+pub use gpui_macros::{
+    AppContext, IntoElement, Render, VisualContext, property_test, register_action, test,
+};
 pub use gpui_util::arc_cow::ArcCow;
 pub use http_client;
 pub use input::*;

crates/gpui/src/interactive.rs 🔗

@@ -17,6 +17,9 @@ pub trait KeyEvent: InputEvent {}
 /// A mouse event from the platform.
 pub trait MouseEvent: InputEvent {}
 
+/// A gesture event from the platform.
+pub trait GestureEvent: InputEvent {}
+
 /// The key down event equivalent for the platform.
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub struct KeyDownEvent {
@@ -467,6 +470,51 @@ impl Default for ScrollDelta {
     }
 }
 
+/// A pinch gesture event from the platform, generated when the user performs
+/// a pinch-to-zoom gesture (typically on a trackpad).
+///
+/// Note: This event is only available on macOS and Wayland (Linux).
+/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+#[derive(Clone, Debug, Default)]
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub struct PinchEvent {
+    /// The position of the pinch center on the window.
+    pub position: Point<Pixels>,
+
+    /// The zoom delta for this event.
+    /// Positive values indicate zooming in, negative values indicate zooming out.
+    /// For example, 0.1 represents a 10% zoom increase.
+    pub delta: f32,
+
+    /// The modifiers that were held down during the pinch gesture.
+    pub modifiers: Modifiers,
+
+    /// The phase of the pinch gesture.
+    pub phase: TouchPhase,
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl Sealed for PinchEvent {}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl InputEvent for PinchEvent {
+    fn to_platform_input(self) -> PlatformInput {
+        PlatformInput::Pinch(self)
+    }
+}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl GestureEvent for PinchEvent {}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl MouseEvent for PinchEvent {}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl Deref for PinchEvent {
+    type Target = Modifiers;
+
+    fn deref(&self) -> &Self::Target {
+        &self.modifiers
+    }
+}
+
 impl ScrollDelta {
     /// Returns true if this is a precise scroll delta in pixels.
     pub fn precise(&self) -> bool {
@@ -626,6 +674,9 @@ pub enum PlatformInput {
     MouseExited(MouseExitEvent),
     /// The scroll wheel was used.
     ScrollWheel(ScrollWheelEvent),
+    /// A pinch gesture was performed.
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    Pinch(PinchEvent),
     /// Files were dragged and dropped onto the window.
     FileDrop(FileDropEvent),
 }
@@ -642,6 +693,8 @@ impl PlatformInput {
             PlatformInput::MousePressure(event) => Some(event),
             PlatformInput::MouseExited(event) => Some(event),
             PlatformInput::ScrollWheel(event) => Some(event),
+            #[cfg(any(target_os = "linux", target_os = "macos"))]
+            PlatformInput::Pinch(event) => Some(event),
             PlatformInput::FileDrop(event) => Some(event),
         }
     }
@@ -657,6 +710,8 @@ impl PlatformInput {
             PlatformInput::MousePressure(_) => None,
             PlatformInput::MouseExited(_) => None,
             PlatformInput::ScrollWheel(_) => None,
+            #[cfg(any(target_os = "linux", target_os = "macos"))]
+            PlatformInput::Pinch(_) => None,
             PlatformInput::FileDrop(_) => None,
         }
     }

crates/gpui/src/platform.rs 🔗

@@ -555,6 +555,20 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     }
 }
 
+/// A renderer for headless windows that can produce real rendered output.
+#[cfg(any(test, feature = "test-support"))]
+pub trait PlatformHeadlessRenderer {
+    /// Render a scene and return the result as an RGBA image.
+    fn render_scene_to_image(
+        &mut self,
+        scene: &Scene,
+        size: Size<DevicePixels>,
+    ) -> Result<RgbaImage>;
+
+    /// Returns the sprite atlas used by this renderer.
+    fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas>;
+}
+
 /// Type alias for runnables with metadata.
 /// Previously an enum with a single variant, now simplified to a direct type alias.
 #[doc(hidden)]
@@ -573,6 +587,7 @@ pub trait PlatformDispatcher: Send + Sync {
     fn dispatch(&self, runnable: RunnableVariant, priority: Priority);
     fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority);
     fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant);
+
     fn spawn_realtime(&self, f: Box<dyn FnOnce() + Send>);
 
     fn now(&self) -> Instant {
@@ -592,19 +607,29 @@ pub trait PlatformDispatcher: Send + Sync {
 #[expect(missing_docs)]
 pub trait PlatformTextSystem: Send + Sync {
     fn add_fonts(&self, fonts: Vec<Cow<'static, [u8]>>) -> Result<()>;
+    /// Get all available font names.
     fn all_font_names(&self) -> Vec<String>;
+    /// Get the font ID for a font descriptor.
     fn font_id(&self, descriptor: &Font) -> Result<FontId>;
+    /// Get metrics for a font.
     fn font_metrics(&self, font_id: FontId) -> FontMetrics;
+    /// Get typographic bounds for a glyph.
     fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Bounds<f32>>;
+    /// Get the advance width for a glyph.
     fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Size<f32>>;
+    /// Get the glyph ID for a character.
     fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option<GlyphId>;
+    /// Get raster bounds for a glyph.
     fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>>;
+    /// Rasterize a glyph.
     fn rasterize_glyph(
         &self,
         params: &RenderGlyphParams,
         raster_bounds: Bounds<DevicePixels>,
     ) -> Result<(Size<DevicePixels>, Vec<u8>)>;
+    /// Layout a line of text with the given font runs.
     fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout;
+    /// Returns the recommended text rendering mode for the given font and size.
     fn recommended_rendering_mode(&self, _font_id: FontId, _font_size: Pixels)
     -> TextRenderingMode;
 }
@@ -1062,6 +1087,13 @@ impl PlatformInputHandler {
     pub fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool {
         self.handler.accepts_text_input(window, cx)
     }
+
+    #[allow(dead_code)]
+    pub fn query_accepts_text_input(&mut self) -> bool {
+        self.cx
+            .update(|window, cx| self.handler.accepts_text_input(window, cx))
+            .unwrap_or(true)
+    }
 }
 
 /// A struct representing a selection in a text buffer, in UTF16 characters.

crates/gpui/src/platform/test/dispatcher.rs 🔗

@@ -30,11 +30,12 @@ impl TestDispatcher {
                 .map_or(false, |var| var == "1" || var == "true"),
             timeout_ticks: 0..=1000,
         }));
+        Self::from_scheduler(scheduler)
+    }
 
-        let session_id = scheduler.allocate_session_id();
-
+    pub fn from_scheduler(scheduler: Arc<TestScheduler>) -> Self {
         TestDispatcher {
-            session_id,
+            session_id: scheduler.allocate_session_id(),
             scheduler,
             num_cpus_override: Arc::new(AtomicUsize::new(0)),
         }
@@ -76,6 +77,14 @@ impl TestDispatcher {
         while self.tick(false) {}
     }
 
+    pub fn allow_parking(&self) {
+        self.scheduler.allow_parking();
+    }
+
+    pub fn forbid_parking(&self) {
+        self.scheduler.forbid_parking();
+    }
+
     /// Override the value returned by `BackgroundExecutor::num_cpus()` in tests.
     /// A value of 0 means no override (the default of 4 is used).
     pub fn set_num_cpus(&self, count: usize) {

crates/gpui/src/platform/test/platform.rs 🔗

@@ -1,9 +1,9 @@
 use crate::{
     AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
     DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
-    PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton,
-    ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task,
-    TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
+    PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
+    PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
+    Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
 };
 use anyhow::Result;
 use collections::VecDeque;
@@ -34,6 +34,7 @@ pub(crate) struct TestPlatform {
     pub opened_url: RefCell<Option<String>>,
     pub text_system: Arc<dyn PlatformTextSystem>,
     pub expect_restart: RefCell<Option<oneshot::Sender<Option<PathBuf>>>>,
+    headless_renderer_factory: Option<Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>>,
     weak: Weak<Self>,
 }
 
@@ -88,8 +89,30 @@ pub(crate) struct TestPrompts {
 
 impl TestPlatform {
     pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc<Self> {
-        let text_system = Arc::new(NoopTextSystem);
-
+        Self::with_platform(
+            executor,
+            foreground_executor,
+            Arc::new(NoopTextSystem),
+            None,
+        )
+    }
+
+    pub fn with_text_system(
+        executor: BackgroundExecutor,
+        foreground_executor: ForegroundExecutor,
+        text_system: Arc<dyn PlatformTextSystem>,
+    ) -> Rc<Self> {
+        Self::with_platform(executor, foreground_executor, text_system, None)
+    }
+
+    pub fn with_platform(
+        executor: BackgroundExecutor,
+        foreground_executor: ForegroundExecutor,
+        text_system: Arc<dyn PlatformTextSystem>,
+        headless_renderer_factory: Option<
+            Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>,
+        >,
+    ) -> Rc<Self> {
         Rc::new_cyclic(|weak| TestPlatform {
             background_executor: executor,
             foreground_executor,
@@ -107,6 +130,7 @@ impl TestPlatform {
             weak: weak.clone(),
             opened_url: Default::default(),
             text_system,
+            headless_renderer_factory,
         })
     }
 
@@ -299,11 +323,13 @@ impl Platform for TestPlatform {
         handle: AnyWindowHandle,
         params: WindowParams,
     ) -> anyhow::Result<Box<dyn crate::PlatformWindow>> {
+        let renderer = self.headless_renderer_factory.as_ref().and_then(|f| f());
         let window = TestWindow::new(
             handle,
             params,
             self.weak.clone(),
             self.active_display.clone(),
+            renderer,
         );
         Ok(Box::new(window))
     }

crates/gpui/src/platform/test/window.rs 🔗

@@ -1,10 +1,12 @@
 use crate::{
-    AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs,
-    Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
-    Point, PromptButton, RequestFrameOptions, Size, TestPlatform, TileId, WindowAppearance,
+    AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels,
+    DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay,
+    PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
+    PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance,
     WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams,
 };
 use collections::HashMap;
+use image::RgbaImage;
 use parking_lot::Mutex;
 use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
 use std::{
@@ -21,6 +23,7 @@ pub(crate) struct TestWindowState {
     platform: Weak<TestPlatform>,
     // TODO: Replace with `Rc`
     sprite_atlas: Arc<dyn PlatformAtlas>,
+    renderer: Option<Box<dyn PlatformHeadlessRenderer>>,
     pub(crate) should_close_handler: Option<Box<dyn FnMut() -> bool>>,
     hit_test_window_control_callback: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
     input_callback: Option<Box<dyn FnMut(PlatformInput) -> DispatchEventResult>>,
@@ -57,13 +60,19 @@ impl TestWindow {
         params: WindowParams,
         platform: Weak<TestPlatform>,
         display: Rc<dyn PlatformDisplay>,
+        renderer: Option<Box<dyn PlatformHeadlessRenderer>>,
     ) -> Self {
+        let sprite_atlas: Arc<dyn PlatformAtlas> = match &renderer {
+            Some(r) => r.sprite_atlas(),
+            None => Arc::new(TestAtlas::new()),
+        };
         Self(Rc::new(Mutex::new(TestWindowState {
             bounds: params.bounds,
             display,
             platform,
             handle,
-            sprite_atlas: Arc::new(TestAtlas::new()),
+            sprite_atlas,
+            renderer,
             title: Default::default(),
             edited: false,
             should_close_handler: None,
@@ -81,10 +90,11 @@ impl TestWindow {
     pub fn simulate_resize(&mut self, size: Size<Pixels>) {
         let scale_factor = self.scale_factor();
         let mut lock = self.0.lock();
+        // Always update bounds, even if no callback is registered
+        lock.bounds.size = size;
         let Some(mut callback) = lock.resize_callback.take() else {
             return;
         };
-        lock.bounds.size = size;
         drop(lock);
         callback(size, scale_factor);
         self.0.lock().resize_callback = Some(callback);
@@ -275,12 +285,25 @@ impl PlatformWindow for TestWindow {
 
     fn on_appearance_changed(&self, _callback: Box<dyn FnMut()>) {}
 
-    fn draw(&self, _scene: &crate::Scene) {}
+    fn draw(&self, _scene: &Scene) {}
 
     fn sprite_atlas(&self) -> sync::Arc<dyn crate::PlatformAtlas> {
         self.0.lock().sprite_atlas.clone()
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    fn render_to_image(&self, scene: &Scene) -> anyhow::Result<RgbaImage> {
+        let mut state = self.0.lock();
+        let size = state.bounds.size;
+        if let Some(renderer) = &mut state.renderer {
+            let scale_factor = 2.0;
+            let device_size: Size<DevicePixels> = size.to_device_pixels(scale_factor);
+            renderer.render_scene_to_image(scene, device_size)
+        } else {
+            anyhow::bail!("render_to_image not available: no HeadlessRenderer configured")
+        }
+    }
+
     fn as_test(&mut self) -> Option<&mut TestWindow> {
         Some(self)
     }

crates/gpui/src/platform_scheduler.rs 🔗

@@ -109,16 +109,13 @@ impl Scheduler for PlatformScheduler {
 
     #[track_caller]
     fn timer(&self, duration: Duration) -> Timer {
-        use std::sync::{Arc, atomic::AtomicBool};
-
         let (tx, rx) = oneshot::channel();
         let dispatcher = self.dispatcher.clone();
 
         // Create a runnable that will send the completion signal
         let location = std::panic::Location::caller();
-        let closed = Arc::new(AtomicBool::new(false));
         let (runnable, _task) = async_task::Builder::new()
-            .metadata(RunnableMeta { location, closed })
+            .metadata(RunnableMeta { location })
             .spawn(
                 move |_| async move {
                     let _ = tx.send(());

crates/gpui/src/scene.rs 🔗

@@ -657,7 +657,7 @@ impl Default for TransformationMatrix {
 #[expect(missing_docs)]
 pub struct MonochromeSprite {
     pub order: DrawOrder,
-    pub pad: u32, // align to 8 bytes
+    pub pad: u32,
     pub bounds: Bounds<ScaledPixels>,
     pub content_mask: ContentMask<ScaledPixels>,
     pub color: Hsla,
@@ -695,7 +695,7 @@ impl From<SubpixelSprite> for Primitive {
 #[expect(missing_docs)]
 pub struct PolychromeSprite {
     pub order: DrawOrder,
-    pub pad: u32, // align to 8 bytes
+    pub pad: u32,
     pub grayscale: bool,
     pub opacity: f32,
     pub bounds: Bounds<ScaledPixels>,

crates/gpui/src/styled.rs 🔗

@@ -384,6 +384,20 @@ pub trait Styled: Sized {
         self
     }
 
+    /// Sets the aspect ratio of the element.
+    /// [Docs](https://tailwindcss.com/docs/aspect-ratio)
+    fn aspect_ratio(mut self, ratio: f32) -> Self {
+        self.style().aspect_ratio = Some(ratio);
+        self
+    }
+
+    /// Sets the aspect ratio of the element to 1/1 – equal width and height.
+    /// [Docs](https://tailwindcss.com/docs/aspect-ratio)
+    fn aspect_square(mut self) -> Self {
+        self.style().aspect_ratio = Some(1.0);
+        self
+    }
+
     /// Sets the background color of the element.
     fn bg<F>(mut self, fill: F) -> Self
     where

crates/gpui/src/test.rs 🔗

@@ -27,12 +27,43 @@
 //! ```
 use crate::{Entity, Subscription, TestAppContext, TestDispatcher};
 use futures::StreamExt as _;
+use proptest::prelude::{Just, Strategy, any};
 use std::{
     env,
-    panic::{self, RefUnwindSafe},
+    panic::{self, RefUnwindSafe, UnwindSafe},
     pin::Pin,
 };
 
+/// Strategy injected into `#[gpui::property_test]` tests to control the seed
+/// given to the scheduler. Doesn't shrink, since all scheduler seeds are
+/// equivalent in complexity. If `$SEED` is set, it always uses that value.
+pub fn seed_strategy() -> impl Strategy<Value = u64> {
+    match std::env::var("SEED") {
+        Ok(val) => Just(val.parse().unwrap()).boxed(),
+        Err(_) => any::<u64>().no_shrink().boxed(),
+    }
+}
+
+/// Similar to [`run_test`], but only runs the callback once, allowing
+/// [`FnOnce`] callbacks. This is intended for use with the
+/// `gpui::property_test` macro and generally should not be used directly.
+///
+/// Doesn't support many features of [`run_test`], since these are provided by
+/// proptest.
+pub fn run_test_once(seed: u64, test_fn: Box<dyn UnwindSafe + FnOnce(TestDispatcher)>) {
+    let result = panic::catch_unwind(|| {
+        let dispatcher = TestDispatcher::new(seed);
+        let scheduler = dispatcher.scheduler().clone();
+        test_fn(dispatcher);
+        scheduler.end_test();
+    });
+
+    match result {
+        Ok(()) => {}
+        Err(e) => panic::resume_unwind(e),
+    }
+}
+
 /// Run the given test function with the configured parameters.
 /// This is intended for use with the `gpui::test` macro
 /// and generally should not be used directly.

crates/gpui/src/text_system.rs 🔗

@@ -63,7 +63,8 @@ pub struct TextSystem {
 }
 
 impl TextSystem {
-    pub(crate) fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
+    /// Create a new TextSystem with the given platform text system.
+    pub fn new(platform_text_system: Arc<dyn PlatformTextSystem>) -> Self {
         TextSystem {
             platform_text_system,
             font_metrics: RwLock::default(),
@@ -372,7 +373,8 @@ pub struct WindowTextSystem {
 }
 
 impl WindowTextSystem {
-    pub(crate) fn new(text_system: Arc<TextSystem>) -> Self {
+    /// Create a new WindowTextSystem with the given TextSystem.
+    pub fn new(text_system: Arc<TextSystem>) -> Self {
         Self {
             line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()),
             text_system,
@@ -438,6 +440,74 @@ impl WindowTextSystem {
         }
     }
 
+    /// Shape the given line using a caller-provided content hash as the cache key.
+    ///
+    /// This enables cache hits without materializing a contiguous `SharedString` for the text.
+    /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping.
+    ///
+    /// Contract (caller enforced):
+    /// - Same `text_hash` implies identical text content (collision risk accepted by caller).
+    /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
+    ///
+    /// Like [`Self::shape_line`], this must be used only for single-line text (no `\n`).
+    pub fn shape_line_by_hash(
+        &self,
+        text_hash: u64,
+        text_len: usize,
+        font_size: Pixels,
+        runs: &[TextRun],
+        force_width: Option<Pixels>,
+        materialize_text: impl FnOnce() -> SharedString,
+    ) -> ShapedLine {
+        let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new();
+        for run in runs {
+            if let Some(last_run) = decoration_runs.last_mut()
+                && last_run.color == run.color
+                && last_run.underline == run.underline
+                && last_run.strikethrough == run.strikethrough
+                && last_run.background_color == run.background_color
+            {
+                last_run.len += run.len as u32;
+                continue;
+            }
+            decoration_runs.push(DecorationRun {
+                len: run.len as u32,
+                color: run.color,
+                background_color: run.background_color,
+                underline: run.underline,
+                strikethrough: run.strikethrough,
+            });
+        }
+
+        let mut used_force_width = force_width;
+        let layout = self.layout_line_by_hash(
+            text_hash,
+            text_len,
+            font_size,
+            runs,
+            used_force_width,
+            || {
+                let text = materialize_text();
+                debug_assert!(
+                    text.find('\n').is_none(),
+                    "text argument should not contain newlines"
+                );
+                text
+            },
+        );
+
+        // We only materialize actual text on cache miss; on hit we avoid allocations.
+        // Since `ShapedLine` carries a `SharedString`, use an empty placeholder for hits.
+        // NOTE: Callers must not rely on `ShapedLine.text` for content when using this API.
+        let text: SharedString = SharedString::new_static("");
+
+        ShapedLine {
+            layout,
+            text,
+            decoration_runs,
+        }
+    }
+
     /// Shape a multi line string of text, at the given font_size, for painting to the screen.
     /// Subsets of the text can be styled independently with the `runs` parameter.
     /// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width.
@@ -627,6 +697,130 @@ impl WindowTextSystem {
 
         layout
     }
+
+    /// Probe the line layout cache using a caller-provided content hash, without allocating.
+    ///
+    /// Returns `Some(layout)` if the layout is already cached in either the current frame
+    /// or the previous frame. Returns `None` if it is not cached.
+    ///
+    /// Contract (caller enforced):
+    /// - Same `text_hash` implies identical text content (collision risk accepted by caller).
+    /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
+    pub fn try_layout_line_by_hash(
+        &self,
+        text_hash: u64,
+        text_len: usize,
+        font_size: Pixels,
+        runs: &[TextRun],
+        force_width: Option<Pixels>,
+    ) -> Option<Arc<LineLayout>> {
+        let mut last_run = None::<&TextRun>;
+        let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default();
+        font_runs.clear();
+
+        for run in runs.iter() {
+            let decoration_changed = if let Some(last_run) = last_run
+                && last_run.color == run.color
+                && last_run.underline == run.underline
+                && last_run.strikethrough == run.strikethrough
+            // we do not consider differing background color relevant, as it does not affect glyphs
+            // && last_run.background_color == run.background_color
+            {
+                false
+            } else {
+                last_run = Some(run);
+                true
+            };
+
+            let font_id = self.resolve_font(&run.font);
+            if let Some(font_run) = font_runs.last_mut()
+                && font_id == font_run.font_id
+                && !decoration_changed
+            {
+                font_run.len += run.len;
+            } else {
+                font_runs.push(FontRun {
+                    len: run.len,
+                    font_id,
+                });
+            }
+        }
+
+        let layout = self.line_layout_cache.try_layout_line_by_hash(
+            text_hash,
+            text_len,
+            font_size,
+            &font_runs,
+            force_width,
+        );
+
+        self.font_runs_pool.lock().push(font_runs);
+
+        layout
+    }
+
+    /// Layout the given line of text using a caller-provided content hash as the cache key.
+    ///
+    /// This enables cache hits without materializing a contiguous `SharedString` for the text.
+    /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping.
+    ///
+    /// Contract (caller enforced):
+    /// - Same `text_hash` implies identical text content (collision risk accepted by caller).
+    /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
+    pub fn layout_line_by_hash(
+        &self,
+        text_hash: u64,
+        text_len: usize,
+        font_size: Pixels,
+        runs: &[TextRun],
+        force_width: Option<Pixels>,
+        materialize_text: impl FnOnce() -> SharedString,
+    ) -> Arc<LineLayout> {
+        let mut last_run = None::<&TextRun>;
+        let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default();
+        font_runs.clear();
+
+        for run in runs.iter() {
+            let decoration_changed = if let Some(last_run) = last_run
+                && last_run.color == run.color
+                && last_run.underline == run.underline
+                && last_run.strikethrough == run.strikethrough
+            // we do not consider differing background color relevant, as it does not affect glyphs
+            // && last_run.background_color == run.background_color
+            {
+                false
+            } else {
+                last_run = Some(run);
+                true
+            };
+
+            let font_id = self.resolve_font(&run.font);
+            if let Some(font_run) = font_runs.last_mut()
+                && font_id == font_run.font_id
+                && !decoration_changed
+            {
+                font_run.len += run.len;
+            } else {
+                font_runs.push(FontRun {
+                    len: run.len,
+                    font_id,
+                });
+            }
+        }
+
+        let layout = self.line_layout_cache.layout_line_by_hash(
+            text_hash,
+            text_len,
+            font_size,
+            &font_runs,
+            force_width,
+            materialize_text,
+        );
+
+        self.font_runs_pool.lock().push(font_runs);
+
+        layout
+    }
 }
 
 #[derive(Hash, Eq, PartialEq)]
@@ -802,6 +996,11 @@ impl TextRun {
 #[repr(C)]
 pub struct GlyphId(pub u32);
 
+/// Parameters for rendering a glyph, used as cache keys for raster bounds.
+///
+/// This struct identifies a specific glyph rendering configuration including
+/// font, size, subpixel positioning, and scale factor. It's used to look up
+/// cached raster bounds and sprite atlas entries.
 #[derive(Clone, Debug, PartialEq)]
 #[expect(missing_docs)]
 pub struct RenderGlyphParams {

crates/gpui/src/text_system/line.rs 🔗

@@ -1,12 +1,24 @@
 use crate::{
-    App, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result, SharedString, StrikethroughStyle,
-    TextAlign, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill, point, px,
-    size,
+    App, Bounds, DevicePixels, Half, Hsla, LineLayout, Pixels, Point, RenderGlyphParams, Result,
+    ShapedGlyph, ShapedRun, SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window,
+    WrapBoundary, WrappedLineLayout, black, fill, point, px, size,
 };
 use derive_more::{Deref, DerefMut};
 use smallvec::SmallVec;
 use std::sync::Arc;
 
+/// Pre-computed glyph data for efficient painting without per-glyph cache lookups.
+///
+/// This is produced by `ShapedLine::compute_glyph_raster_data` during prepaint
+/// and consumed by `ShapedLine::paint_with_raster_data` during paint.
+#[derive(Clone, Debug)]
+pub struct GlyphRasterData {
+    /// The raster bounds for each glyph, in paint order.
+    pub bounds: Vec<Bounds<DevicePixels>>,
+    /// The render params for each glyph (needed for sprite atlas lookup).
+    pub params: Vec<RenderGlyphParams>,
+}
+
 /// Set the text decoration for a run of text.
 #[derive(Debug, Clone)]
 pub struct DecorationRun {
@@ -44,6 +56,14 @@ impl ShapedLine {
         self.layout.len
     }
 
+    /// The width of the shaped line in pixels.
+    ///
+    /// This is the glyph advance width computed by the text shaping system and is useful for
+    /// incrementally advancing a "pen" when painting multiple fragments on the same row.
+    pub fn width(&self) -> Pixels {
+        self.layout.width
+    }
+
     /// Override the len, useful if you're rendering text a
     /// as text b (e.g. rendering invisibles).
     pub fn with_len(mut self, len: usize) -> Self {
@@ -108,6 +128,120 @@ impl ShapedLine {
 
         Ok(())
     }
+
+    /// Split this shaped line at a byte index, returning `(prefix, suffix)`.
+    ///
+    /// - `prefix` contains glyphs for bytes `[0, byte_index)` with original positions.
+    ///   Its width equals the x-advance up to the split point.
+    /// - `suffix` contains glyphs for bytes `[byte_index, len)` with positions
+    ///   shifted left so the first glyph starts at x=0, and byte indices rebased to 0.
+    /// - Decoration runs are partitioned at the boundary; a run that straddles it is
+    ///   split into two with adjusted lengths.
+    /// - `font_size`, `ascent`, and `descent` are copied to both halves.
+    pub fn split_at(&self, byte_index: usize) -> (ShapedLine, ShapedLine) {
+        let x_offset = self.layout.x_for_index(byte_index);
+
+        // Partition glyph runs. A single run may contribute glyphs to both halves.
+        let mut left_runs = Vec::new();
+        let mut right_runs = Vec::new();
+
+        for run in &self.layout.runs {
+            let split_pos = run.glyphs.partition_point(|g| g.index < byte_index);
+
+            if split_pos > 0 {
+                left_runs.push(ShapedRun {
+                    font_id: run.font_id,
+                    glyphs: run.glyphs[..split_pos].to_vec(),
+                });
+            }
+
+            if split_pos < run.glyphs.len() {
+                let right_glyphs = run.glyphs[split_pos..]
+                    .iter()
+                    .map(|g| ShapedGlyph {
+                        id: g.id,
+                        position: point(g.position.x - x_offset, g.position.y),
+                        index: g.index - byte_index,
+                        is_emoji: g.is_emoji,
+                    })
+                    .collect();
+                right_runs.push(ShapedRun {
+                    font_id: run.font_id,
+                    glyphs: right_glyphs,
+                });
+            }
+        }
+
+        // Partition decoration runs. A run straddling the boundary is split into two.
+        let mut left_decorations = SmallVec::new();
+        let mut right_decorations = SmallVec::new();
+        let mut decoration_offset = 0u32;
+        let split_point = byte_index as u32;
+
+        for decoration in &self.decoration_runs {
+            let run_end = decoration_offset + decoration.len;
+
+            if run_end <= split_point {
+                left_decorations.push(decoration.clone());
+            } else if decoration_offset >= split_point {
+                right_decorations.push(decoration.clone());
+            } else {
+                let left_len = split_point - decoration_offset;
+                let right_len = run_end - split_point;
+                left_decorations.push(DecorationRun {
+                    len: left_len,
+                    color: decoration.color,
+                    background_color: decoration.background_color,
+                    underline: decoration.underline,
+                    strikethrough: decoration.strikethrough,
+                });
+                right_decorations.push(DecorationRun {
+                    len: right_len,
+                    color: decoration.color,
+                    background_color: decoration.background_color,
+                    underline: decoration.underline,
+                    strikethrough: decoration.strikethrough,
+                });
+            }
+
+            decoration_offset = run_end;
+        }
+
+        // Split text
+        let left_text = SharedString::new(self.text[..byte_index].to_string());
+        let right_text = SharedString::new(self.text[byte_index..].to_string());
+
+        let left_width = x_offset;
+        let right_width = self.layout.width - left_width;
+
+        let left = ShapedLine {
+            layout: Arc::new(LineLayout {
+                font_size: self.layout.font_size,
+                width: left_width,
+                ascent: self.layout.ascent,
+                descent: self.layout.descent,
+                runs: left_runs,
+                len: byte_index,
+            }),
+            text: left_text,
+            decoration_runs: left_decorations,
+        };
+
+        let right = ShapedLine {
+            layout: Arc::new(LineLayout {
+                font_size: self.layout.font_size,
+                width: right_width,
+                ascent: self.layout.ascent,
+                descent: self.layout.descent,
+                runs: right_runs,
+                len: self.layout.len - byte_index,
+            }),
+            text: right_text,
+            decoration_runs: right_decorations,
+        };
+
+        (left, right)
+    }
 }
 
 /// A line of text that has been shaped, decorated, and wrapped by the text layout system.
@@ -594,3 +728,268 @@ fn aligned_origin_x(
         TextAlign::Right => origin.x + align_width - line_width,
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{FontId, GlyphId};
+
+    /// Helper: build a ShapedLine from glyph descriptors without the platform text system.
+    /// Each glyph is described as (byte_index, x_position).
+    fn make_shaped_line(
+        text: &str,
+        glyphs: &[(usize, f32)],
+        width: f32,
+        decorations: &[DecorationRun],
+    ) -> ShapedLine {
+        let shaped_glyphs: Vec<ShapedGlyph> = glyphs
+            .iter()
+            .map(|&(index, x)| ShapedGlyph {
+                id: GlyphId(0),
+                position: point(px(x), px(0.0)),
+                index,
+                is_emoji: false,
+            })
+            .collect();
+
+        ShapedLine {
+            layout: Arc::new(LineLayout {
+                font_size: px(16.0),
+                width: px(width),
+                ascent: px(12.0),
+                descent: px(4.0),
+                runs: vec![ShapedRun {
+                    font_id: FontId(0),
+                    glyphs: shaped_glyphs,
+                }],
+                len: text.len(),
+            }),
+            text: SharedString::new(text.to_string()),
+            decoration_runs: SmallVec::from(decorations.to_vec()),
+        }
+    }
+
+    #[test]
+    fn test_split_at_invariants() {
+        // Split "abcdef" at every possible byte index and verify structural invariants.
+        let line = make_shaped_line(
+            "abcdef",
+            &[
+                (0, 0.0),
+                (1, 10.0),
+                (2, 20.0),
+                (3, 30.0),
+                (4, 40.0),
+                (5, 50.0),
+            ],
+            60.0,
+            &[],
+        );
+
+        for i in 0..=6 {
+            let (left, right) = line.split_at(i);
+
+            assert_eq!(
+                left.width() + right.width(),
+                line.width(),
+                "widths must sum at split={i}"
+            );
+            assert_eq!(
+                left.len() + right.len(),
+                line.len(),
+                "lengths must sum at split={i}"
+            );
+            assert_eq!(
+                format!("{}{}", left.text.as_ref(), right.text.as_ref()),
+                "abcdef",
+                "text must concatenate at split={i}"
+            );
+            assert_eq!(left.font_size, line.font_size, "font_size at split={i}");
+            assert_eq!(right.ascent, line.ascent, "ascent at split={i}");
+            assert_eq!(right.descent, line.descent, "descent at split={i}");
+        }
+
+        // Edge: split at 0 produces no left runs, full content on right
+        let (left, right) = line.split_at(0);
+        assert_eq!(left.runs.len(), 0);
+        assert_eq!(right.runs[0].glyphs.len(), 6);
+
+        // Edge: split at end produces full content on left, no right runs
+        let (left, right) = line.split_at(6);
+        assert_eq!(left.runs[0].glyphs.len(), 6);
+        assert_eq!(right.runs.len(), 0);
+    }
+
+    #[test]
+    fn test_split_at_glyph_rebasing() {
+        // Two font runs (simulating a font fallback boundary at byte 3):
+        //   run A (FontId 0): glyphs at bytes 0,1,2  positions 0,10,20
+        //   run B (FontId 1): glyphs at bytes 3,4,5  positions 30,40,50
+        // Successive splits simulate the incremental splitting done during wrap.
+        let line = ShapedLine {
+            layout: Arc::new(LineLayout {
+                font_size: px(16.0),
+                width: px(60.0),
+                ascent: px(12.0),
+                descent: px(4.0),
+                runs: vec![
+                    ShapedRun {
+                        font_id: FontId(0),
+                        glyphs: vec![
+                            ShapedGlyph {
+                                id: GlyphId(0),
+                                position: point(px(0.0), px(0.0)),
+                                index: 0,
+                                is_emoji: false,
+                            },
+                            ShapedGlyph {
+                                id: GlyphId(0),
+                                position: point(px(10.0), px(0.0)),
+                                index: 1,
+                                is_emoji: false,
+                            },
+                            ShapedGlyph {
+                                id: GlyphId(0),
+                                position: point(px(20.0), px(0.0)),
+                                index: 2,
+                                is_emoji: false,
+                            },
+                        ],
+                    },
+                    ShapedRun {
+                        font_id: FontId(1),
+                        glyphs: vec![
+                            ShapedGlyph {
+                                id: GlyphId(0),
+                                position: point(px(30.0), px(0.0)),
+                                index: 3,
+                                is_emoji: false,
+                            },
+                            ShapedGlyph {
+                                id: GlyphId(0),
+                                position: point(px(40.0), px(0.0)),
+                                index: 4,
+                                is_emoji: false,
+                            },
+                            ShapedGlyph {
+                                id: GlyphId(0),
+                                position: point(px(50.0), px(0.0)),
+                                index: 5,
+                                is_emoji: false,
+                            },
+                        ],
+                    },
+                ],
+                len: 6,
+            }),
+            text: SharedString::new("abcdef".to_string()),
+            decoration_runs: SmallVec::new(),
+        };
+
+        // First split at byte 2 — mid-run in run A
+        let (first, remainder) = line.split_at(2);
+        assert_eq!(first.text.as_ref(), "ab");
+        assert_eq!(first.runs.len(), 1);
+        assert_eq!(first.runs[0].font_id, FontId(0));
+
+        // Remainder "cdef" should have two runs: tail of A (1 glyph) + all of B (3 glyphs)
+        assert_eq!(remainder.text.as_ref(), "cdef");
+        assert_eq!(remainder.runs.len(), 2);
+        assert_eq!(remainder.runs[0].font_id, FontId(0));
+        assert_eq!(remainder.runs[0].glyphs.len(), 1);
+        assert_eq!(remainder.runs[0].glyphs[0].index, 0);
+        assert_eq!(remainder.runs[0].glyphs[0].position.x, px(0.0));
+        assert_eq!(remainder.runs[1].font_id, FontId(1));
+        assert_eq!(remainder.runs[1].glyphs[0].index, 1);
+        assert_eq!(remainder.runs[1].glyphs[0].position.x, px(10.0));
+
+        // Second split at byte 2 within remainder — crosses the run boundary
+        let (second, final_part) = remainder.split_at(2);
+        assert_eq!(second.text.as_ref(), "cd");
+        assert_eq!(final_part.text.as_ref(), "ef");
+        assert_eq!(final_part.runs[0].glyphs[0].index, 0);
+        assert_eq!(final_part.runs[0].glyphs[0].position.x, px(0.0));
+
+        // Widths must sum across all three pieces
+        assert_eq!(
+            first.width() + second.width() + final_part.width(),
+            line.width()
+        );
+    }
+
+    #[test]
+    fn test_split_at_decorations() {
+        // Three decoration runs: red [0..2), green [2..5), blue [5..6).
+        // Split at byte 3 — red goes entirely left, green straddles, blue goes entirely right.
+        let red = Hsla {
+            h: 0.0,
+            s: 1.0,
+            l: 0.5,
+            a: 1.0,
+        };
+        let green = Hsla {
+            h: 0.3,
+            s: 1.0,
+            l: 0.5,
+            a: 1.0,
+        };
+        let blue = Hsla {
+            h: 0.6,
+            s: 1.0,
+            l: 0.5,
+            a: 1.0,
+        };
+
+        let line = make_shaped_line(
+            "abcdef",
+            &[
+                (0, 0.0),
+                (1, 10.0),
+                (2, 20.0),
+                (3, 30.0),
+                (4, 40.0),
+                (5, 50.0),
+            ],
+            60.0,
+            &[
+                DecorationRun {
+                    len: 2,
+                    color: red,
+                    background_color: None,
+                    underline: None,
+                    strikethrough: None,
+                },
+                DecorationRun {
+                    len: 3,
+                    color: green,
+                    background_color: None,
+                    underline: None,
+                    strikethrough: None,
+                },
+                DecorationRun {
+                    len: 1,
+                    color: blue,
+                    background_color: None,
+                    underline: None,
+                    strikethrough: None,
+                },
+            ],
+        );
+
+        let (left, right) = line.split_at(3);
+
+        // Left: red(2) + green(1) — green straddled, left portion has len 1
+        assert_eq!(left.decoration_runs.len(), 2);
+        assert_eq!(left.decoration_runs[0].len, 2);
+        assert_eq!(left.decoration_runs[0].color, red);
+        assert_eq!(left.decoration_runs[1].len, 1);
+        assert_eq!(left.decoration_runs[1].color, green);
+
+        // Right: green(2) + blue(1) — green straddled, right portion has len 2
+        assert_eq!(right.decoration_runs.len(), 2);
+        assert_eq!(right.decoration_runs[0].len, 2);
+        assert_eq!(right.decoration_runs[0].color, green);
+        assert_eq!(right.decoration_runs[1].len, 1);
+        assert_eq!(right.decoration_runs[1].color, blue);
+    }
+}

crates/gpui/src/text_system/line_layout.rs 🔗

@@ -401,12 +401,25 @@ struct FrameCache {
     wrapped_lines: FxHashMap<Arc<CacheKey>, Arc<WrappedLineLayout>>,
     used_lines: Vec<Arc<CacheKey>>,
     used_wrapped_lines: Vec<Arc<CacheKey>>,
+
+    // Content-addressable caches keyed by caller-provided text hash + layout params.
+    // These allow cache hits without materializing a contiguous `SharedString`.
+    //
+    // IMPORTANT: To support allocation-free lookups, we store these maps using a key type
+    // (`HashedCacheKeyRef`) that can be computed without building a contiguous `&str`/`SharedString`.
+    // On miss, we allocate once and store under an owned `HashedCacheKey`.
+    lines_by_hash: FxHashMap<Arc<HashedCacheKey>, Arc<LineLayout>>,
+    wrapped_lines_by_hash: FxHashMap<Arc<HashedCacheKey>, Arc<WrappedLineLayout>>,
+    used_lines_by_hash: Vec<Arc<HashedCacheKey>>,
+    used_wrapped_lines_by_hash: Vec<Arc<HashedCacheKey>>,
 }
 
 #[derive(Clone, Default)]
 pub(crate) struct LineLayoutIndex {
     lines_index: usize,
     wrapped_lines_index: usize,
+    lines_by_hash_index: usize,
+    wrapped_lines_by_hash_index: usize,
 }
 
 impl LineLayoutCache {
@@ -423,6 +436,8 @@ impl LineLayoutCache {
         LineLayoutIndex {
             lines_index: frame.used_lines.len(),
             wrapped_lines_index: frame.used_wrapped_lines.len(),
+            lines_by_hash_index: frame.used_lines_by_hash.len(),
+            wrapped_lines_by_hash_index: frame.used_wrapped_lines_by_hash.len(),
         }
     }
 
@@ -445,6 +460,24 @@ impl LineLayoutCache {
             }
             current_frame.used_wrapped_lines.push(key.clone());
         }
+
+        for key in &previous_frame.used_lines_by_hash
+            [range.start.lines_by_hash_index..range.end.lines_by_hash_index]
+        {
+            if let Some((key, line)) = previous_frame.lines_by_hash.remove_entry(key) {
+                current_frame.lines_by_hash.insert(key, line);
+            }
+            current_frame.used_lines_by_hash.push(key.clone());
+        }
+
+        for key in &previous_frame.used_wrapped_lines_by_hash
+            [range.start.wrapped_lines_by_hash_index..range.end.wrapped_lines_by_hash_index]
+        {
+            if let Some((key, line)) = previous_frame.wrapped_lines_by_hash.remove_entry(key) {
+                current_frame.wrapped_lines_by_hash.insert(key, line);
+            }
+            current_frame.used_wrapped_lines_by_hash.push(key.clone());
+        }
     }
 
     pub fn truncate_layouts(&self, index: LineLayoutIndex) {
@@ -453,6 +486,12 @@ impl LineLayoutCache {
         current_frame
             .used_wrapped_lines
             .truncate(index.wrapped_lines_index);
+        current_frame
+            .used_lines_by_hash
+            .truncate(index.lines_by_hash_index);
+        current_frame
+            .used_wrapped_lines_by_hash
+            .truncate(index.wrapped_lines_by_hash_index);
     }
 
     pub fn finish_frame(&self) {
@@ -463,6 +502,11 @@ impl LineLayoutCache {
         curr_frame.wrapped_lines.clear();
         curr_frame.used_lines.clear();
         curr_frame.used_wrapped_lines.clear();
+
+        curr_frame.lines_by_hash.clear();
+        curr_frame.wrapped_lines_by_hash.clear();
+        curr_frame.used_lines_by_hash.clear();
+        curr_frame.used_wrapped_lines_by_hash.clear();
     }
 
     pub fn layout_wrapped_line<Text>(
@@ -590,6 +634,165 @@ impl LineLayoutCache {
             layout
         }
     }
+
+    /// Try to retrieve a previously-shaped line layout using a caller-provided content hash.
+    ///
+    /// This is a *non-allocating* cache probe: it does not materialize any text. If the layout
+    /// is not already cached in either the current frame or previous frame, returns `None`.
+    ///
+    /// Contract (caller enforced):
+    /// - Same `text_hash` implies identical text content (collision risk accepted by caller).
+    /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
+    pub fn try_layout_line_by_hash(
+        &self,
+        text_hash: u64,
+        text_len: usize,
+        font_size: Pixels,
+        runs: &[FontRun],
+        force_width: Option<Pixels>,
+    ) -> Option<Arc<LineLayout>> {
+        let key_ref = HashedCacheKeyRef {
+            text_hash,
+            text_len,
+            font_size,
+            runs,
+            wrap_width: None,
+            force_width,
+        };
+
+        let current_frame = self.current_frame.read();
+        if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| {
+            HashedCacheKeyRef {
+                text_hash: key.text_hash,
+                text_len: key.text_len,
+                font_size: key.font_size,
+                runs: key.runs.as_slice(),
+                wrap_width: key.wrap_width,
+                force_width: key.force_width,
+            } == key_ref
+        }) {
+            return Some(layout.clone());
+        }
+
+        let previous_frame = self.previous_frame.lock();
+        if let Some((_, layout)) = previous_frame.lines_by_hash.iter().find(|(key, _)| {
+            HashedCacheKeyRef {
+                text_hash: key.text_hash,
+                text_len: key.text_len,
+                font_size: key.font_size,
+                runs: key.runs.as_slice(),
+                wrap_width: key.wrap_width,
+                force_width: key.force_width,
+            } == key_ref
+        }) {
+            return Some(layout.clone());
+        }
+
+        None
+    }
+
+    /// Layout a line of text using a caller-provided content hash as the cache key.
+    ///
+    /// This enables cache hits without materializing a contiguous `SharedString` for `text`.
+    /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping.
+    ///
+    /// Contract (caller enforced):
+    /// - Same `text_hash` implies identical text content (collision risk accepted by caller).
+    /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions).
+    pub fn layout_line_by_hash(
+        &self,
+        text_hash: u64,
+        text_len: usize,
+        font_size: Pixels,
+        runs: &[FontRun],
+        force_width: Option<Pixels>,
+        materialize_text: impl FnOnce() -> SharedString,
+    ) -> Arc<LineLayout> {
+        let key_ref = HashedCacheKeyRef {
+            text_hash,
+            text_len,
+            font_size,
+            runs,
+            wrap_width: None,
+            force_width,
+        };
+
+        // Fast path: already cached (no allocation).
+        let current_frame = self.current_frame.upgradable_read();
+        if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| {
+            HashedCacheKeyRef {
+                text_hash: key.text_hash,
+                text_len: key.text_len,
+                font_size: key.font_size,
+                runs: key.runs.as_slice(),
+                wrap_width: key.wrap_width,
+                force_width: key.force_width,
+            } == key_ref
+        }) {
+            return layout.clone();
+        }
+
+        let mut current_frame = RwLockUpgradableReadGuard::upgrade(current_frame);
+
+        // Try to reuse from previous frame without allocating; do a linear scan to find a matching key.
+        // (We avoid `drain()` here because it would eagerly move all entries.)
+        let mut previous_frame = self.previous_frame.lock();
+        if let Some(existing_key) = previous_frame
+            .used_lines_by_hash
+            .iter()
+            .find(|key| {
+                HashedCacheKeyRef {
+                    text_hash: key.text_hash,
+                    text_len: key.text_len,
+                    font_size: key.font_size,
+                    runs: key.runs.as_slice(),
+                    wrap_width: key.wrap_width,
+                    force_width: key.force_width,
+                } == key_ref
+            })
+            .cloned()
+        {
+            if let Some((key, layout)) = previous_frame.lines_by_hash.remove_entry(&existing_key) {
+                current_frame
+                    .lines_by_hash
+                    .insert(key.clone(), layout.clone());
+                current_frame.used_lines_by_hash.push(key);
+                return layout;
+            }
+        }
+
+        let text = materialize_text();
+        let mut layout = self
+            .platform_text_system
+            .layout_line(&text, font_size, runs);
+
+        if let Some(force_width) = force_width {
+            let mut glyph_pos = 0;
+            for run in layout.runs.iter_mut() {
+                for glyph in run.glyphs.iter_mut() {
+                    if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) {
+                        glyph.position.x = glyph_pos * force_width;
+                    }
+                    glyph_pos += 1;
+                }
+            }
+        }
+
+        let key = Arc::new(HashedCacheKey {
+            text_hash,
+            text_len,
+            font_size,
+            runs: SmallVec::from(runs),
+            wrap_width: None,
+            force_width,
+        });
+        let layout = Arc::new(layout);
+        current_frame
+            .lines_by_hash
+            .insert(key.clone(), layout.clone());
+        current_frame.used_lines_by_hash.push(key);
+        layout
+    }
 }
 
 /// A run of text with a single font.
@@ -622,12 +825,80 @@ struct CacheKeyRef<'a> {
     force_width: Option<Pixels>,
 }
 
+#[derive(Clone, Debug)]
+struct HashedCacheKey {
+    text_hash: u64,
+    text_len: usize,
+    font_size: Pixels,
+    runs: SmallVec<[FontRun; 1]>,
+    wrap_width: Option<Pixels>,
+    force_width: Option<Pixels>,
+}
+
+#[derive(Copy, Clone)]
+struct HashedCacheKeyRef<'a> {
+    text_hash: u64,
+    text_len: usize,
+    font_size: Pixels,
+    runs: &'a [FontRun],
+    wrap_width: Option<Pixels>,
+    force_width: Option<Pixels>,
+}
+
 impl PartialEq for dyn AsCacheKeyRef + '_ {
     fn eq(&self, other: &dyn AsCacheKeyRef) -> bool {
         self.as_cache_key_ref() == other.as_cache_key_ref()
     }
 }
 
+impl PartialEq for HashedCacheKey {
+    fn eq(&self, other: &Self) -> bool {
+        self.text_hash == other.text_hash
+            && self.text_len == other.text_len
+            && self.font_size == other.font_size
+            && self.runs.as_slice() == other.runs.as_slice()
+            && self.wrap_width == other.wrap_width
+            && self.force_width == other.force_width
+    }
+}
+
+impl Eq for HashedCacheKey {}
+
+impl Hash for HashedCacheKey {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.text_hash.hash(state);
+        self.text_len.hash(state);
+        self.font_size.hash(state);
+        self.runs.as_slice().hash(state);
+        self.wrap_width.hash(state);
+        self.force_width.hash(state);
+    }
+}
+
+impl PartialEq for HashedCacheKeyRef<'_> {
+    fn eq(&self, other: &Self) -> bool {
+        self.text_hash == other.text_hash
+            && self.text_len == other.text_len
+            && self.font_size == other.font_size
+            && self.runs == other.runs
+            && self.wrap_width == other.wrap_width
+            && self.force_width == other.force_width
+    }
+}
+
+impl Eq for HashedCacheKeyRef<'_> {}
+
+impl Hash for HashedCacheKeyRef<'_> {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.text_hash.hash(state);
+        self.text_len.hash(state);
+        self.font_size.hash(state);
+        self.runs.hash(state);
+        self.wrap_width.hash(state);
+        self.force_width.hash(state);
+    }
+}
+
 impl Eq for dyn AsCacheKeyRef + '_ {}
 
 impl Hash for dyn AsCacheKeyRef + '_ {

crates/gpui/src/window.rs 🔗

@@ -566,6 +566,10 @@ impl HitboxId {
     ///
     /// See [`Hitbox::is_hovered`] for details.
     pub fn is_hovered(self, window: &Window) -> bool {
+        // If this hitbox has captured the pointer, it's always considered hovered
+        if window.captured_hitbox == Some(self) {
+            return true;
+        }
         let hit_test = &window.mouse_hit_test;
         for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) {
             if self == *id {
@@ -822,6 +826,11 @@ impl Frame {
         self.tab_stops.clear();
         self.focus = None;
 
+        #[cfg(any(test, feature = "test-support"))]
+        {
+            self.debug_bounds.clear();
+        }
+
         #[cfg(any(feature = "inspector", debug_assertions))]
         {
             self.next_inspector_instance_ids.clear();
@@ -952,6 +961,9 @@ pub struct Window {
     pub(crate) pending_input_observers: SubscriberSet<(), AnyObserver>,
     prompt: Option<RenderablePromptHandle>,
     pub(crate) client_inset: Option<Pixels>,
+    /// The hitbox that has captured the pointer, if any.
+    /// While captured, mouse events route to this hitbox regardless of hit testing.
+    captured_hitbox: Option<HitboxId>,
     #[cfg(any(feature = "inspector", debug_assertions))]
     inspector: Option<Entity<Inspector>>,
 }
@@ -1439,6 +1451,7 @@ impl Window {
             prompt: None,
             client_inset: None,
             image_cache_stack: Vec::new(),
+            captured_hitbox: None,
             #[cfg(any(feature = "inspector", debug_assertions))]
             inspector: None,
         })
@@ -1888,7 +1901,12 @@ impl Window {
         })
     }
 
-    fn bounds_changed(&mut self, cx: &mut App) {
+    /// Notify the window that its bounds have changed.
+    ///
+    /// This updates internal state like `viewport_size` and `scale_factor` from
+    /// the platform window, then notifies observers. Normally called automatically
+    /// by the platform's resize callback, but exposed publicly for test infrastructure.
+    pub fn bounds_changed(&mut self, cx: &mut App) {
         self.scale_factor = self.platform_window.scale_factor();
         self.viewport_size = self.platform_window.content_size();
         self.display_id = self.platform_window.display().map(|display| display.id());
@@ -2144,6 +2162,26 @@ impl Window {
         self.mouse_position
     }
 
+    /// Captures the pointer for the given hitbox. While captured, all mouse move and mouse up
+    /// events will be routed to listeners that check this hitbox's `is_hovered` status,
+    /// regardless of actual hit testing. This enables drag operations that continue
+    /// even when the pointer moves outside the element's bounds.
+    ///
+    /// The capture is automatically released on mouse up.
+    pub fn capture_pointer(&mut self, hitbox_id: HitboxId) {
+        self.captured_hitbox = Some(hitbox_id);
+    }
+
+    /// Releases any active pointer capture.
+    pub fn release_pointer(&mut self) {
+        self.captured_hitbox = None;
+    }
+
+    /// Returns the hitbox that has captured the pointer, if any.
+    pub fn captured_hitbox(&self) -> Option<HitboxId> {
+        self.captured_hitbox
+    }
+
     /// The current state of the keyboard's modifiers
     pub fn modifiers(&self) -> Modifiers {
         self.modifiers
@@ -3295,6 +3333,100 @@ impl Window {
         Ok(())
     }
 
+    /// Paints a monochrome glyph with pre-computed raster bounds.
+    ///
+    /// This is faster than `paint_glyph` because it skips the per-glyph cache lookup.
+    /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint.
+    pub fn paint_glyph_with_raster_bounds(
+        &mut self,
+        origin: Point<Pixels>,
+        _font_id: FontId,
+        _glyph_id: GlyphId,
+        _font_size: Pixels,
+        color: Hsla,
+        raster_bounds: Bounds<DevicePixels>,
+        params: &RenderGlyphParams,
+    ) -> Result<()> {
+        self.invalidator.debug_assert_paint();
+
+        let element_opacity = self.element_opacity();
+        let scale_factor = self.scale_factor();
+        let glyph_origin = origin.scale(scale_factor);
+
+        if !raster_bounds.is_zero() {
+            let tile = self
+                .sprite_atlas
+                .get_or_insert_with(&params.clone().into(), &mut || {
+                    let (size, bytes) = self.text_system().rasterize_glyph(params)?;
+                    Ok(Some((size, Cow::Owned(bytes))))
+                })?
+                .expect("Callback above only errors or returns Some");
+            let bounds = Bounds {
+                origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into),
+                size: tile.bounds.size.map(Into::into),
+            };
+            let content_mask = self.content_mask().scale(scale_factor);
+            self.next_frame.scene.insert_primitive(MonochromeSprite {
+                order: 0,
+                pad: 0,
+                bounds,
+                content_mask,
+                color: color.opacity(element_opacity),
+                tile,
+                transformation: TransformationMatrix::unit(),
+            });
+        }
+        Ok(())
+    }
+
+    /// Paints an emoji glyph with pre-computed raster bounds.
+    ///
+    /// This is faster than `paint_emoji` because it skips the per-glyph cache lookup.
+    /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint.
+    pub fn paint_emoji_with_raster_bounds(
+        &mut self,
+        origin: Point<Pixels>,
+        _font_id: FontId,
+        _glyph_id: GlyphId,
+        _font_size: Pixels,
+        raster_bounds: Bounds<DevicePixels>,
+        params: &RenderGlyphParams,
+    ) -> Result<()> {
+        self.invalidator.debug_assert_paint();
+
+        let scale_factor = self.scale_factor();
+        let glyph_origin = origin.scale(scale_factor);
+
+        if !raster_bounds.is_zero() {
+            let tile = self
+                .sprite_atlas
+                .get_or_insert_with(&params.clone().into(), &mut || {
+                    let (size, bytes) = self.text_system().rasterize_glyph(params)?;
+                    Ok(Some((size, Cow::Owned(bytes))))
+                })?
+                .expect("Callback above only errors or returns Some");
+
+            let bounds = Bounds {
+                origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into),
+                size: tile.bounds.size.map(Into::into),
+            };
+            let content_mask = self.content_mask().scale(scale_factor);
+            let opacity = self.element_opacity();
+
+            self.next_frame.scene.insert_primitive(PolychromeSprite {
+                order: 0,
+                pad: 0,
+                grayscale: false,
+                bounds,
+                corner_radii: Default::default(),
+                content_mask,
+                tile,
+                opacity,
+            });
+        }
+        Ok(())
+    }
+
     fn should_use_subpixel_rendering(&self, font_id: FontId, font_size: Pixels) -> bool {
         if self.platform_window.background_appearance() != WindowBackgroundAppearance::Opaque {
             return false;
@@ -3945,6 +4077,12 @@ impl Window {
                 self.modifiers = scroll_wheel.modifiers;
                 PlatformInput::ScrollWheel(scroll_wheel)
             }
+            #[cfg(any(target_os = "linux", target_os = "macos"))]
+            PlatformInput::Pinch(pinch) => {
+                self.mouse_position = pinch.position;
+                self.modifiers = pinch.modifiers;
+                PlatformInput::Pinch(pinch)
+            }
             // Translate dragging and dropping of external files from the operating system
             // to internal drag and drop events.
             PlatformInput::FileDrop(file_drop) => match file_drop {
@@ -4057,6 +4195,11 @@ impl Window {
                 self.refresh();
             }
         }
+
+        // Auto-release pointer capture on mouse up
+        if event.is::<MouseUpEvent>() && self.captured_hitbox.is_some() {
+            self.captured_hitbox = None;
+        }
     }
 
     fn dispatch_key_event(&mut self, event: &dyn Any, cx: &mut App) {

crates/gpui_linux/src/linux/dispatcher.rs 🔗

@@ -44,11 +44,6 @@ impl LinuxDispatcher {
                     .name(format!("Worker-{i}"))
                     .spawn(move || {
                         for runnable in receiver.iter() {
-                            // Check if the executor that spawned this task was closed
-                            if runnable.metadata().is_closed() {
-                                continue;
-                            }
-
                             let start = Instant::now();
 
                             let location = runnable.metadata().location;
@@ -94,11 +89,6 @@ impl LinuxDispatcher {
                                     calloop::timer::Timer::from_duration(timer.duration),
                                     move |_, _, _| {
                                         if let Some(runnable) = runnable.take() {
-                                            // Check if the executor that spawned this task was closed
-                                            if runnable.metadata().is_closed() {
-                                                return TimeoutAction::Drop;
-                                            }
-
                                             let start = Instant::now();
                                             let location = runnable.metadata().location;
                                             let mut timing = TaskTiming {

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

@@ -36,6 +36,9 @@ use wayland_client::{
         wl_shm_pool, wl_surface,
     },
 };
+use wayland_protocols::wp::pointer_gestures::zv1::client::{
+    zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1,
+};
 use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{
     self, ZwpPrimarySelectionOfferV1,
 };
@@ -95,7 +98,7 @@ use gpui::{
     ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowParams, point,
     profiler, px, size,
 };
-use gpui_wgpu::{CompositorGpuHint, WgpuContext};
+use gpui_wgpu::{CompositorGpuHint, GpuContext};
 use wayland_protocols::wp::linux_dmabuf::zv1::client::{
     zwp_linux_dmabuf_feedback_v1, zwp_linux_dmabuf_v1,
 };
@@ -124,6 +127,7 @@ pub struct Globals {
     pub layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
     pub blur_manager: Option<org_kde_kwin_blur_manager::OrgKdeKwinBlurManager>,
     pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
+    pub gesture_manager: Option<zwp_pointer_gestures_v1::ZwpPointerGesturesV1>,
     pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
     pub executor: ForegroundExecutor,
 }
@@ -164,6 +168,7 @@ impl Globals {
             layer_shell: globals.bind(&qh, 1..=5, ()).ok(),
             blur_manager: globals.bind(&qh, 1..=1, ()).ok(),
             text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
+            gesture_manager: globals.bind(&qh, 1..=3, ()).ok(),
             dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
             executor,
             qh,
@@ -204,10 +209,12 @@ pub struct Output {
 pub(crate) struct WaylandClientState {
     serial_tracker: SerialTracker,
     globals: Globals,
-    pub gpu_context: Option<WgpuContext>,
+    pub gpu_context: GpuContext,
     pub compositor_gpu: Option<CompositorGpuHint>,
     wl_seat: wl_seat::WlSeat, // TODO: Multi seat support
     wl_pointer: Option<wl_pointer::WlPointer>,
+    pinch_gesture: Option<zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1>,
+    pinch_scale: f32,
     wl_keyboard: Option<wl_keyboard::WlKeyboard>,
     cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
     data_device: Option<wl_data_device::WlDataDevice>,
@@ -221,6 +228,7 @@ pub(crate) struct WaylandClientState {
     // Output to scale mapping
     outputs: HashMap<ObjectId, Output>,
     in_progress_outputs: HashMap<ObjectId, InProgressOutput>,
+    wl_outputs: HashMap<ObjectId, wl_output::WlOutput>,
     keyboard_layout: LinuxKeyboardLayout,
     keymap_state: Option<xkb::State>,
     compose_state: Option<xkb::compose::State>,
@@ -463,6 +471,8 @@ impl WaylandClient {
         let mut seat: Option<wl_seat::WlSeat> = None;
         #[allow(clippy::mutable_key_type)]
         let mut in_progress_outputs = HashMap::default();
+        #[allow(clippy::mutable_key_type)]
+        let mut wl_outputs: HashMap<ObjectId, wl_output::WlOutput> = HashMap::default();
         globals.contents().with_list(|list| {
             for global in list {
                 match &global.interface[..] {
@@ -482,6 +492,7 @@ impl WaylandClient {
                             (),
                         );
                         in_progress_outputs.insert(output.id(), InProgressOutput::default());
+                        wl_outputs.insert(output.id(), output);
                     }
                     _ => {}
                 }
@@ -520,7 +531,7 @@ impl WaylandClient {
             .unwrap();
 
         let compositor_gpu = detect_compositor_gpu();
-        let gpu_context = None;
+        let gpu_context = Rc::new(RefCell::new(None));
 
         let seat = seat.unwrap();
         let globals = Globals::new(
@@ -580,6 +591,8 @@ impl WaylandClient {
             wl_seat: seat,
             wl_pointer: None,
             wl_keyboard: None,
+            pinch_gesture: None,
+            pinch_scale: 1.0,
             cursor_shape_device: None,
             data_device,
             primary_selection,
@@ -589,6 +602,7 @@ impl WaylandClient {
             composing: false,
             outputs: HashMap::default(),
             in_progress_outputs,
+            wl_outputs,
             windows: HashMap::default(),
             common,
             keyboard_layout: LinuxKeyboardLayout::new(UNKNOWN_KEYBOARD_LAYOUT_NAME),
@@ -720,17 +734,27 @@ impl LinuxClient for WaylandClient {
 
         let parent = state.keyboard_focused_window.clone();
 
+        let target_output = params.display_id.and_then(|display_id| {
+            let target_protocol_id: u32 = display_id.into();
+            state
+                .wl_outputs
+                .iter()
+                .find(|(id, _)| id.protocol_id() == target_protocol_id)
+                .map(|(_, output)| output.clone())
+        });
+
         let appearance = state.common.appearance;
         let compositor_gpu = state.compositor_gpu.take();
         let (window, surface_id) = WaylandWindow::new(
             handle,
             state.globals.clone(),
-            &mut state.gpu_context,
+            state.gpu_context.clone(),
             compositor_gpu,
             WaylandClientStatePtr(Rc::downgrade(&self.0)),
             params,
             appearance,
             parent,
+            target_output,
         )?;
         state.windows.insert(surface_id, window.0.clone());
 
@@ -1020,6 +1044,7 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for WaylandClientStat
                     state
                         .in_progress_outputs
                         .insert(output.id(), InProgressOutput::default());
+                    state.wl_outputs.insert(output.id(), output);
                 }
                 _ => {}
             },
@@ -1309,6 +1334,12 @@ impl Dispatch<wl_seat::WlSeat, ()> for WaylandClientStatePtr {
                     .as_ref()
                     .map(|cursor_shape_manager| cursor_shape_manager.get_pointer(&pointer, qh, ()));
 
+                state.pinch_gesture = state.globals.gesture_manager.as_ref().map(
+                    |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| {
+                        gesture_manager.get_pinch_gesture(&pointer, qh, ())
+                    },
+                );
+
                 if let Some(wl_pointer) = &state.wl_pointer {
                     wl_pointer.release();
                 }
@@ -1982,6 +2013,91 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
     }
 }
 
+impl Dispatch<zwp_pointer_gestures_v1::ZwpPointerGesturesV1, ()> for WaylandClientStatePtr {
+    fn event(
+        _this: &mut Self,
+        _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1,
+        _: <zwp_pointer_gestures_v1::ZwpPointerGesturesV1 as Proxy>::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        // The gesture manager doesn't generate events
+    }
+}
+
+impl Dispatch<zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, ()>
+    for WaylandClientStatePtr
+{
+    fn event(
+        this: &mut Self,
+        _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1,
+        event: <zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1 as Proxy>::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        use gpui::PinchEvent;
+
+        let client = this.get_client();
+        let mut state = client.borrow_mut();
+
+        let Some(window) = state.mouse_focused_window.clone() else {
+            return;
+        };
+
+        match event {
+            zwp_pointer_gesture_pinch_v1::Event::Begin {
+                serial: _,
+                time: _,
+                surface: _,
+                fingers: _,
+            } => {
+                state.pinch_scale = 1.0;
+                let input = PlatformInput::Pinch(PinchEvent {
+                    position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))),
+                    delta: 0.0,
+                    modifiers: state.modifiers,
+                    phase: TouchPhase::Started,
+                });
+                drop(state);
+                window.handle_input(input);
+            }
+            zwp_pointer_gesture_pinch_v1::Event::Update { time: _, scale, .. } => {
+                let new_absolute_scale = scale as f32;
+                let previous_scale = state.pinch_scale;
+                let zoom_delta = new_absolute_scale - previous_scale;
+                state.pinch_scale = new_absolute_scale;
+
+                let input = PlatformInput::Pinch(PinchEvent {
+                    position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))),
+                    delta: zoom_delta,
+                    modifiers: state.modifiers,
+                    phase: TouchPhase::Moved,
+                });
+                drop(state);
+                window.handle_input(input);
+            }
+            zwp_pointer_gesture_pinch_v1::Event::End {
+                serial: _,
+                time: _,
+                cancelled: _,
+            } => {
+                state.pinch_scale = 1.0;
+                let input = PlatformInput::Pinch(PinchEvent {
+                    position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))),
+                    delta: 0.0,
+                    modifiers: state.modifiers,
+                    phase: TouchPhase::Ended,
+                });
+                drop(state);
+                window.handle_input(input);
+            }
+            _ => {}
+        }
+    }
+}
+
 impl Dispatch<wp_fractional_scale_v1::WpFractionalScaleV1, ObjectId> for WaylandClientStatePtr {
     fn event(
         this: &mut Self,

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

@@ -12,7 +12,10 @@ use futures::channel::oneshot::Receiver;
 use raw_window_handle as rwh;
 use wayland_backend::client::ObjectId;
 use wayland_client::WEnum;
-use wayland_client::{Proxy, protocol::wl_surface};
+use wayland_client::{
+    Proxy,
+    protocol::{wl_output, wl_surface},
+};
 use wayland_protocols::wp::viewporter::client::wp_viewport;
 use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1;
 use wayland_protocols::xdg::shell::client::xdg_surface;
@@ -34,7 +37,7 @@ use gpui::{
     WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px,
     size,
 };
-use gpui_wgpu::{CompositorGpuHint, WgpuContext, WgpuRenderer, WgpuSurfaceConfig};
+use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig};
 
 #[derive(Default)]
 pub(crate) struct Callbacks {
@@ -129,6 +132,7 @@ impl WaylandSurfaceState {
         globals: &Globals,
         params: &WindowParams,
         parent: Option<WaylandWindowStatePtr>,
+        target_output: Option<wl_output::WlOutput>,
     ) -> anyhow::Result<Self> {
         // For layer_shell windows, create a layer surface instead of an xdg surface
         if let WindowKind::LayerShell(options) = &params.kind {
@@ -138,7 +142,7 @@ impl WaylandSurfaceState {
 
             let layer_surface = layer_shell.get_layer_surface(
                 &surface,
-                None,
+                target_output.as_ref(),
                 super::layer_shell::wayland_layer(options.layer),
                 options.namespace.clone(),
                 &globals.qh,
@@ -317,7 +321,7 @@ impl WaylandWindowState {
         viewport: Option<wp_viewport::WpViewport>,
         client: WaylandClientStatePtr,
         globals: Globals,
-        gpu_context: &mut Option<WgpuContext>,
+        gpu_context: gpui_wgpu::GpuContext,
         compositor_gpu: Option<CompositorGpuHint>,
         options: WindowParams,
         parent: Option<WaylandWindowStatePtr>,
@@ -488,15 +492,17 @@ impl WaylandWindow {
     pub fn new(
         handle: AnyWindowHandle,
         globals: Globals,
-        gpu_context: &mut Option<WgpuContext>,
+        gpu_context: gpui_wgpu::GpuContext,
         compositor_gpu: Option<CompositorGpuHint>,
         client: WaylandClientStatePtr,
         params: WindowParams,
         appearance: WindowAppearance,
         parent: Option<WaylandWindowStatePtr>,
+        target_output: Option<wl_output::WlOutput>,
     ) -> anyhow::Result<(Self, ObjectId)> {
         let surface = globals.compositor.create_surface(&globals.qh, ());
-        let surface_state = WaylandSurfaceState::new(&surface, &globals, &params, parent.clone())?;
+        let surface_state =
+            WaylandSurfaceState::new(&surface, &globals, &params, parent.clone(), target_output)?;
 
         if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() {
             fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id());
@@ -1251,6 +1257,7 @@ impl PlatformWindow for WaylandWindow {
         let state = client.borrow();
         state
             .gpu_context
+            .borrow()
             .as_ref()
             .is_some_and(|ctx| ctx.supports_dual_source_blending())
     }
@@ -1328,6 +1335,41 @@ impl PlatformWindow for WaylandWindow {
 
     fn draw(&self, scene: &Scene) {
         let mut state = self.borrow_mut();
+
+        if state.renderer.device_lost() {
+            let raw_window = RawWindow {
+                window: state.surface.id().as_ptr().cast::<std::ffi::c_void>(),
+                display: state
+                    .surface
+                    .backend()
+                    .upgrade()
+                    .unwrap()
+                    .display_ptr()
+                    .cast::<std::ffi::c_void>(),
+            };
+            let display_handle = rwh::HasDisplayHandle::display_handle(&raw_window)
+                .unwrap()
+                .as_raw();
+            let window_handle = rwh::HasWindowHandle::window_handle(&raw_window)
+                .unwrap()
+                .as_raw();
+
+            state
+                .renderer
+                .recover(display_handle, window_handle)
+                .unwrap_or_else(|err| {
+                    panic!(
+                        "GPU device lost and recovery failed. \
+                        This may happen after system suspend/resume. \
+                        Please restart the application.\n\nError: {err}"
+                    )
+                });
+
+            // The current scene references atlas textures that were cleared during recovery.
+            // Skip this frame and let the next frame rebuild the scene with fresh textures.
+            return;
+        }
+
         state.renderer.draw(scene);
     }
 

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

@@ -64,7 +64,7 @@ use gpui::{
     PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size,
     TouchPhase, WindowParams, point, px,
 };
-use gpui_wgpu::{CompositorGpuHint, WgpuContext};
+use gpui_wgpu::{CompositorGpuHint, GpuContext};
 
 /// Value for DeviceId parameters which selects all devices.
 pub(crate) const XINPUT_ALL_DEVICES: xinput::DeviceId = 0;
@@ -177,7 +177,7 @@ pub struct X11ClientState {
     pub(crate) last_location: Point<Pixels>,
     pub(crate) current_count: usize,
 
-    pub(crate) gpu_context: Option<WgpuContext>,
+    pub(crate) gpu_context: GpuContext,
     pub(crate) compositor_gpu: Option<CompositorGpuHint>,
 
     pub(crate) scale_factor: f32,
@@ -295,7 +295,7 @@ impl X11ClientStatePtr {
 }
 
 #[derive(Clone)]
-pub(crate) struct X11Client(Rc<RefCell<X11ClientState>>);
+pub(crate) struct X11Client(pub(crate) Rc<RefCell<X11ClientState>>);
 
 impl X11Client {
     pub(crate) fn new() -> anyhow::Result<Self> {
@@ -493,7 +493,7 @@ impl X11Client {
             last_mouse_button: None,
             last_location: Point::new(px(0.0), px(0.0)),
             current_count: 0,
-            gpu_context: None,
+            gpu_context: Rc::new(RefCell::new(None)),
             compositor_gpu,
             scale_factor,
 
@@ -602,6 +602,9 @@ impl X11Client {
                     Ok(None) => {
                         break;
                     }
+                    Err(err @ ConnectionError::IoError(..)) => {
+                        return Err(EventHandlerError::from(err));
+                    }
                     Err(err) => {
                         let err = handle_connection_error(err);
                         log::warn!("error while polling for X11 events: {err:?}");
@@ -1524,7 +1527,7 @@ impl LinuxClient for X11Client {
             handle,
             X11ClientStatePtr(Rc::downgrade(&self.0)),
             state.common.foreground_executor.clone(),
-            &mut state.gpu_context,
+            state.gpu_context.clone(),
             compositor_gpu,
             params,
             &xcb_connection,

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

@@ -9,7 +9,7 @@ use gpui::{
     Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea,
     WindowDecorations, WindowKind, WindowParams, px,
 };
-use gpui_wgpu::{CompositorGpuHint, WgpuContext, WgpuRenderer, WgpuSurfaceConfig};
+use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig};
 
 use collections::FxHashSet;
 use raw_window_handle as rwh;
@@ -259,6 +259,8 @@ pub struct X11WindowState {
     executor: ForegroundExecutor,
     atoms: XcbAtoms,
     x_root_window: xproto::Window,
+    x_screen_index: usize,
+    visual_id: u32,
     pub(crate) counter_id: sync::Counter,
     pub(crate) last_sync_counter: Option<sync::Int64>,
     bounds: Bounds<Pixels>,
@@ -407,7 +409,7 @@ impl X11WindowState {
         handle: AnyWindowHandle,
         client: X11ClientStatePtr,
         executor: ForegroundExecutor,
-        gpu_context: &mut Option<WgpuContext>,
+        gpu_context: gpui_wgpu::GpuContext,
         compositor_gpu: Option<CompositorGpuHint>,
         params: WindowParams,
         xcb: &Rc<XCBConnection>,
@@ -727,6 +729,8 @@ impl X11WindowState {
                 executor,
                 display,
                 x_root_window: visual_set.root,
+                x_screen_index,
+                visual_id: visual.id,
                 bounds: bounds.to_pixels(scale_factor),
                 scale_factor,
                 renderer,
@@ -819,7 +823,7 @@ impl X11Window {
         handle: AnyWindowHandle,
         client: X11ClientStatePtr,
         executor: ForegroundExecutor,
-        gpu_context: &mut Option<WgpuContext>,
+        gpu_context: gpui_wgpu::GpuContext,
         compositor_gpu: Option<CompositorGpuHint>,
         params: WindowParams,
         xcb: &Rc<XCBConnection>,
@@ -1173,13 +1177,11 @@ impl X11WindowStatePtr {
     }
 
     pub fn set_bounds(&self, bounds: Bounds<i32>) -> anyhow::Result<()> {
-        let mut resize_args = None;
-        let is_resize;
-        {
+        let (is_resize, content_size, scale_factor) = {
             let mut state = self.state.borrow_mut();
             let bounds = bounds.map(|f| px(f as f32 / state.scale_factor));
 
-            is_resize = bounds.size.width != state.bounds.size.width
+            let is_resize = bounds.size.width != state.bounds.size.width
                 || bounds.size.height != state.bounds.size.height;
 
             // If it's a resize event (only width/height changed), we ignore `bounds.origin`
@@ -1191,22 +1193,19 @@ impl X11WindowStatePtr {
             }
 
             let gpu_size = query_render_extent(&self.xcb, self.x_window)?;
-            if true {
-                state.renderer.update_drawable_size(gpu_size);
-                resize_args = Some((state.content_size(), state.scale_factor));
-            }
+            state.renderer.update_drawable_size(gpu_size);
+            let result = (is_resize, state.content_size(), state.scale_factor);
             if let Some(value) = state.last_sync_counter.take() {
                 check_reply(
                     || "X11 sync SetCounter failed.",
                     sync::set_counter(&self.xcb, state.counter_id, value),
                 )?;
             }
-        }
+            result
+        };
 
         let mut callbacks = self.callbacks.borrow_mut();
-        if let Some((content_size, scale_factor)) = resize_args
-            && let Some(ref mut fun) = callbacks.resize
-        {
+        if let Some(ref mut fun) = callbacks.resize {
             fun(content_size, scale_factor)
         }
 
@@ -1499,6 +1498,7 @@ impl PlatformWindow for X11Window {
                 let state = ref_cell.borrow();
                 state
                     .gpu_context
+                    .borrow()
                     .as_ref()
                     .is_some_and(|ctx| ctx.supports_dual_source_blending())
             })
@@ -1593,6 +1593,39 @@ impl PlatformWindow for X11Window {
 
     fn draw(&self, scene: &Scene) {
         let mut inner = self.0.state.borrow_mut();
+
+        if inner.renderer.device_lost() {
+            let raw_window = RawWindow {
+                connection: as_raw_xcb_connection::AsRawXcbConnection::as_raw_xcb_connection(
+                    &*self.0.xcb,
+                ) as *mut _,
+                screen_id: inner.x_screen_index,
+                window_id: self.0.x_window,
+                visual_id: inner.visual_id,
+            };
+            let display_handle = rwh::HasDisplayHandle::display_handle(&raw_window)
+                .unwrap()
+                .as_raw();
+            let window_handle = rwh::HasWindowHandle::window_handle(&raw_window)
+                .unwrap()
+                .as_raw();
+
+            inner
+                .renderer
+                .recover(display_handle, window_handle)
+                .unwrap_or_else(|err| {
+                    panic!(
+                        "GPU device lost and recovery failed. \
+                        This may happen after system suspend/resume. \
+                        Please restart the application.\n\nError: {err}"
+                    )
+                });
+
+            // The current scene references atlas textures that were cleared during recovery.
+            // Skip this frame and let the next frame rebuild the scene with fresh textures.
+            return;
+        }
+
         inner.renderer.draw(scene);
     }
 

crates/gpui_macos/src/dispatcher.rs 🔗

@@ -201,14 +201,7 @@ extern "C" fn trampoline(context: *mut c_void) {
     let runnable =
         unsafe { Runnable::<RunnableMeta>::from_raw(NonNull::new_unchecked(context as *mut ())) };
 
-    let metadata = runnable.metadata();
-
-    // Check if the executor that spawned this task was closed
-    if metadata.is_closed() {
-        return;
-    }
-
-    let location = metadata.location;
+    let location = runnable.metadata().location;
 
     let start = Instant::now();
     let timing = TaskTiming {

crates/gpui_macos/src/events.rs 🔗

@@ -1,8 +1,8 @@
 use gpui::{
     Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
     MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent,
-    NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent,
-    TouchPhase, point, px,
+    NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta,
+    ScrollWheelEvent, TouchPhase, point, px,
 };
 
 use crate::{
@@ -234,6 +234,27 @@ pub(crate) unsafe fn platform_input_from_native(
                     _ => None,
                 }
             }
+            NSEventType::NSEventTypeMagnify => window_height.map(|window_height| {
+                let phase = match native_event.phase() {
+                    NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => {
+                        TouchPhase::Started
+                    }
+                    NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended,
+                    _ => TouchPhase::Moved,
+                };
+
+                let magnification = native_event.magnification() as f32;
+
+                PlatformInput::Pinch(PinchEvent {
+                    position: point(
+                        px(native_event.locationInWindow().x as f32),
+                        window_height - px(native_event.locationInWindow().y as f32),
+                    ),
+                    delta: magnification,
+                    modifiers: read_modifiers(native_event),
+                    phase,
+                })
+            }),
             NSEventType::NSScrollWheel => window_height.map(|window_height| {
                 let phase = match native_event.phase() {
                     NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => {

crates/gpui_macos/src/metal_renderer.rs 🔗

@@ -110,10 +110,12 @@ impl InstanceBufferPool {
 
 pub(crate) struct MetalRenderer {
     device: metal::Device,
-    layer: metal::MetalLayer,
+    layer: Option<metal::MetalLayer>,
     is_apple_gpu: bool,
     is_unified_memory: bool,
     presents_with_transaction: bool,
+    /// For headless rendering, tracks whether output should be opaque
+    opaque: bool,
     command_queue: CommandQueue,
     paths_rasterization_pipeline_state: metal::RenderPipelineState,
     path_sprites_pipeline_state: metal::RenderPipelineState,
@@ -142,26 +144,9 @@ pub struct PathRasterizationVertex {
 }
 
 impl MetalRenderer {
+    /// Creates a new MetalRenderer with a CAMetalLayer for window-based rendering.
     pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>, transparent: bool) -> Self {
-        // Prefer low‐power integrated GPUs on Intel Mac. On Apple
-        // Silicon, there is only ever one GPU, so this is equivalent to
-        // `metal::Device::system_default()`.
-        let device = if let Some(d) = metal::Device::all()
-            .into_iter()
-            .min_by_key(|d| (d.is_removable(), !d.is_low_power()))
-        {
-            d
-        } else {
-            // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689
-            // In that case, we fall back to the system default device.
-            log::error!(
-                "Unable to enumerate Metal devices; attempting to use system default device"
-            );
-            metal::Device::system_default().unwrap_or_else(|| {
-                log::error!("unable to access a compatible graphics device");
-                std::process::exit(1);
-            })
-        };
+        let device = Self::create_device();
 
         let layer = metal::MetalLayer::new();
         layer.set_device(&device);
@@ -182,6 +167,48 @@ impl MetalRenderer {
                     | AutoresizingMask::HEIGHT_SIZABLE
             ];
         }
+
+        Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool)
+    }
+
+    /// Creates a new headless MetalRenderer for offscreen rendering without a window.
+    ///
+    /// This renderer can render scenes to images without requiring a CAMetalLayer,
+    /// window, or AppKit. Use `render_scene_to_image()` to render scenes.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn new_headless(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>) -> Self {
+        let device = Self::create_device();
+        Self::new_internal(device, None, true, instance_buffer_pool)
+    }
+
+    fn create_device() -> metal::Device {
+        // Prefer low‐power integrated GPUs on Intel Mac. On Apple
+        // Silicon, there is only ever one GPU, so this is equivalent to
+        // `metal::Device::system_default()`.
+        if let Some(d) = metal::Device::all()
+            .into_iter()
+            .min_by_key(|d| (d.is_removable(), !d.is_low_power()))
+        {
+            d
+        } else {
+            // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689
+            // In that case, we fall back to the system default device.
+            log::error!(
+                "Unable to enumerate Metal devices; attempting to use system default device"
+            );
+            metal::Device::system_default().unwrap_or_else(|| {
+                log::error!("unable to access a compatible graphics device");
+                std::process::exit(1);
+            })
+        }
+    }
+
+    fn new_internal(
+        device: metal::Device,
+        layer: Option<metal::MetalLayer>,
+        opaque: bool,
+        instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>,
+    ) -> Self {
         #[cfg(feature = "runtime_shaders")]
         let library = device
             .new_library_with_source(&SHADERS_SOURCE_FILE, &metal::CompileOptions::new())
@@ -303,6 +330,7 @@ impl MetalRenderer {
             presents_with_transaction: false,
             is_apple_gpu,
             is_unified_memory,
+            opaque,
             command_queue,
             paths_rasterization_pipeline_state,
             path_sprites_pipeline_state,
@@ -322,12 +350,15 @@ impl MetalRenderer {
         }
     }
 
-    pub fn layer(&self) -> &metal::MetalLayerRef {
-        &self.layer
+    pub fn layer(&self) -> Option<&metal::MetalLayerRef> {
+        self.layer.as_ref().map(|l| l.as_ref())
     }
 
     pub fn layer_ptr(&self) -> *mut CAMetalLayer {
-        self.layer.as_ptr()
+        self.layer
+            .as_ref()
+            .map(|l| l.as_ptr())
+            .unwrap_or(ptr::null_mut())
     }
 
     pub fn sprite_atlas(&self) -> &Arc<MetalAtlas> {
@@ -336,26 +367,25 @@ impl MetalRenderer {
 
     pub fn set_presents_with_transaction(&mut self, presents_with_transaction: bool) {
         self.presents_with_transaction = presents_with_transaction;
-        self.layer
-            .set_presents_with_transaction(presents_with_transaction);
+        if let Some(layer) = &self.layer {
+            layer.set_presents_with_transaction(presents_with_transaction);
+        }
     }
 
     pub fn update_drawable_size(&mut self, size: Size<DevicePixels>) {
-        let size = NSSize {
-            width: size.width.0 as f64,
-            height: size.height.0 as f64,
-        };
-        unsafe {
-            let _: () = msg_send![
-                self.layer(),
-                setDrawableSize: size
-            ];
+        if let Some(layer) = &self.layer {
+            let ns_size = NSSize {
+                width: size.width.0 as f64,
+                height: size.height.0 as f64,
+            };
+            unsafe {
+                let _: () = msg_send![
+                    layer.as_ref(),
+                    setDrawableSize: ns_size
+                ];
+            }
         }
-        let device_pixels_size = Size {
-            width: DevicePixels(size.width as i32),
-            height: DevicePixels(size.height as i32),
-        };
-        self.update_path_intermediate_textures(device_pixels_size);
+        self.update_path_intermediate_textures(size);
     }
 
     fn update_path_intermediate_textures(&mut self, size: Size<DevicePixels>) {
@@ -396,8 +426,11 @@ impl MetalRenderer {
         }
     }
 
-    pub fn update_transparency(&self, transparent: bool) {
-        self.layer.set_opaque(!transparent);
+    pub fn update_transparency(&mut self, transparent: bool) {
+        self.opaque = !transparent;
+        if let Some(layer) = &self.layer {
+            layer.set_opaque(!transparent);
+        }
     }
 
     pub fn destroy(&self) {
@@ -405,7 +438,15 @@ impl MetalRenderer {
     }
 
     pub fn draw(&mut self, scene: &Scene) {
-        let layer = self.layer.clone();
+        let layer = match &self.layer {
+            Some(l) => l.clone(),
+            None => {
+                log::error!(
+                    "draw() called on headless renderer - use render_scene_to_image() instead"
+                );
+                return;
+            }
+        };
         let viewport_size = layer.drawable_size();
         let viewport_size: Size<DevicePixels> = size(
             (viewport_size.width.ceil() as i32).into(),
@@ -476,9 +517,15 @@ impl MetalRenderer {
     /// Renders the scene to a texture and returns the pixel data as an RGBA image.
     /// This does not present the frame to screen - useful for visual testing
     /// where we want to capture what would be rendered without displaying it.
+    ///
+    /// Note: This requires a layer-backed renderer. For headless rendering,
+    /// use `render_scene_to_image()` instead.
     #[cfg(any(test, feature = "test-support"))]
     pub fn render_to_image(&mut self, scene: &Scene) -> Result<RgbaImage> {
-        let layer = self.layer.clone();
+        let layer = self
+            .layer
+            .clone()
+            .ok_or_else(|| anyhow::anyhow!("render_to_image requires a layer-backed renderer"))?;
         let viewport_size = layer.drawable_size();
         let viewport_size: Size<DevicePixels> = size(
             (viewport_size.width.ceil() as i32).into(),
@@ -567,21 +614,146 @@ impl MetalRenderer {
         }
     }
 
+    /// Renders a scene to an image without requiring a window or CAMetalLayer.
+    ///
+    /// This is the primary method for headless rendering. It creates an offscreen
+    /// texture, renders the scene to it, and returns the pixel data as an RGBA image.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn render_scene_to_image(
+        &mut self,
+        scene: &Scene,
+        size: Size<DevicePixels>,
+    ) -> Result<RgbaImage> {
+        if size.width.0 <= 0 || size.height.0 <= 0 {
+            anyhow::bail!("Invalid size for render_scene_to_image: {:?}", size);
+        }
+
+        // Update path intermediate textures for this size
+        self.update_path_intermediate_textures(size);
+
+        // Create an offscreen texture as render target
+        let texture_descriptor = metal::TextureDescriptor::new();
+        texture_descriptor.set_width(size.width.0 as u64);
+        texture_descriptor.set_height(size.height.0 as u64);
+        texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm);
+        texture_descriptor
+            .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
+        texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed);
+        let target_texture = self.device.new_texture(&texture_descriptor);
+
+        loop {
+            let mut instance_buffer = self
+                .instance_buffer_pool
+                .lock()
+                .acquire(&self.device, self.is_unified_memory);
+
+            let command_buffer =
+                self.draw_primitives_to_texture(scene, &mut instance_buffer, &target_texture, size);
+
+            match command_buffer {
+                Ok(command_buffer) => {
+                    let instance_buffer_pool = self.instance_buffer_pool.clone();
+                    let instance_buffer = Cell::new(Some(instance_buffer));
+                    let block = ConcreteBlock::new(move |_| {
+                        if let Some(instance_buffer) = instance_buffer.take() {
+                            instance_buffer_pool.lock().release(instance_buffer);
+                        }
+                    });
+                    let block = block.copy();
+                    command_buffer.add_completed_handler(&block);
+
+                    // On discrete GPUs (non-unified memory), Managed textures
+                    // require an explicit blit synchronize before the CPU can
+                    // read back the rendered data. Without this, get_bytes
+                    // returns stale zeros.
+                    if !self.is_unified_memory {
+                        let blit = command_buffer.new_blit_command_encoder();
+                        blit.synchronize_resource(&target_texture);
+                        blit.end_encoding();
+                    }
+
+                    // Commit and wait for completion
+                    command_buffer.commit();
+                    command_buffer.wait_until_completed();
+
+                    // Read pixels from the texture
+                    let width = size.width.0 as u32;
+                    let height = size.height.0 as u32;
+                    let bytes_per_row = width as usize * 4;
+                    let buffer_size = height as usize * bytes_per_row;
+
+                    let mut pixels = vec![0u8; buffer_size];
+
+                    let region = metal::MTLRegion {
+                        origin: metal::MTLOrigin { x: 0, y: 0, z: 0 },
+                        size: metal::MTLSize {
+                            width: width as u64,
+                            height: height as u64,
+                            depth: 1,
+                        },
+                    };
+
+                    target_texture.get_bytes(
+                        pixels.as_mut_ptr() as *mut std::ffi::c_void,
+                        bytes_per_row as u64,
+                        region,
+                        0,
+                    );
+
+                    // Convert BGRA to RGBA (swap B and R channels)
+                    for chunk in pixels.chunks_exact_mut(4) {
+                        chunk.swap(0, 2);
+                    }
+
+                    return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| {
+                        anyhow::anyhow!("Failed to create RgbaImage from pixel data")
+                    });
+                }
+                Err(err) => {
+                    log::error!(
+                        "failed to render: {}. retrying with larger instance buffer size",
+                        err
+                    );
+                    let mut instance_buffer_pool = self.instance_buffer_pool.lock();
+                    let buffer_size = instance_buffer_pool.buffer_size;
+                    if buffer_size >= 256 * 1024 * 1024 {
+                        anyhow::bail!("instance buffer size grew too large: {}", buffer_size);
+                    }
+                    instance_buffer_pool.reset(buffer_size * 2);
+                    log::info!(
+                        "increased instance buffer size to {}",
+                        instance_buffer_pool.buffer_size
+                    );
+                }
+            }
+        }
+    }
+
     fn draw_primitives(
         &mut self,
         scene: &Scene,
         instance_buffer: &mut InstanceBuffer,
         drawable: &metal::MetalDrawableRef,
         viewport_size: Size<DevicePixels>,
+    ) -> Result<metal::CommandBuffer> {
+        self.draw_primitives_to_texture(scene, instance_buffer, drawable.texture(), viewport_size)
+    }
+
+    fn draw_primitives_to_texture(
+        &mut self,
+        scene: &Scene,
+        instance_buffer: &mut InstanceBuffer,
+        texture: &metal::TextureRef,
+        viewport_size: Size<DevicePixels>,
     ) -> Result<metal::CommandBuffer> {
         let command_queue = self.command_queue.clone();
         let command_buffer = command_queue.new_command_buffer();
-        let alpha = if self.layer.is_opaque() { 1. } else { 0. };
+        let alpha = if self.opaque { 1. } else { 0. };
         let mut instance_offset = 0;
 
-        let mut command_encoder = new_command_encoder(
+        let mut command_encoder = new_command_encoder_for_texture(
             command_buffer,
-            drawable,
+            texture,
             viewport_size,
             |color_attachment| {
                 color_attachment.set_load_action(metal::MTLLoadAction::Clear);
@@ -617,9 +789,9 @@ impl MetalRenderer {
                         command_buffer,
                     );
 
-                    command_encoder = new_command_encoder(
+                    command_encoder = new_command_encoder_for_texture(
                         command_buffer,
-                        drawable,
+                        texture,
                         viewport_size,
                         |color_attachment| {
                             color_attachment.set_load_action(metal::MTLLoadAction::Load);
@@ -1309,9 +1481,9 @@ impl MetalRenderer {
     }
 }
 
-fn new_command_encoder<'a>(
+fn new_command_encoder_for_texture<'a>(
     command_buffer: &'a metal::CommandBufferRef,
-    drawable: &'a metal::MetalDrawableRef,
+    texture: &'a metal::TextureRef,
     viewport_size: Size<DevicePixels>,
     configure_color_attachment: impl Fn(&RenderPassColorAttachmentDescriptorRef),
 ) -> &'a metal::RenderCommandEncoderRef {
@@ -1320,7 +1492,7 @@ fn new_command_encoder<'a>(
         .color_attachments()
         .object_at(0)
         .unwrap();
-    color_attachment.set_texture(Some(drawable.texture()));
+    color_attachment.set_texture(Some(texture));
     color_attachment.set_store_action(metal::MTLStoreAction::Store);
     configure_color_attachment(color_attachment);
 
@@ -1506,3 +1678,32 @@ pub struct SurfaceBounds {
     pub bounds: Bounds<ScaledPixels>,
     pub content_mask: ContentMask<ScaledPixels>,
 }
+
+#[cfg(any(test, feature = "test-support"))]
+pub struct MetalHeadlessRenderer {
+    renderer: MetalRenderer,
+}
+
+#[cfg(any(test, feature = "test-support"))]
+impl MetalHeadlessRenderer {
+    pub fn new() -> Self {
+        let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default()));
+        let renderer = MetalRenderer::new_headless(instance_buffer_pool);
+        Self { renderer }
+    }
+}
+
+#[cfg(any(test, feature = "test-support"))]
+impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer {
+    fn render_scene_to_image(
+        &mut self,
+        scene: &Scene,
+        size: Size<DevicePixels>,
+    ) -> anyhow::Result<image::RgbaImage> {
+        self.renderer.render_scene_to_image(scene, size)
+    }
+
+    fn sprite_atlas(&self) -> Arc<dyn gpui::PlatformAtlas> {
+        self.renderer.sprite_atlas().clone()
+    }
+}

crates/gpui_macos/src/text_system.rs 🔗

@@ -53,7 +53,8 @@ use crate::open_type::apply_features_and_fallbacks;
 #[allow(non_upper_case_globals)]
 const kCGImageAlphaOnly: u32 = 7;
 
-pub(crate) struct MacTextSystem(RwLock<MacTextSystemState>);
+/// macOS text system using CoreText for font shaping.
+pub struct MacTextSystem(RwLock<MacTextSystemState>);
 
 #[derive(Clone, PartialEq, Eq, Hash)]
 struct FontKey {
@@ -73,7 +74,8 @@ struct MacTextSystemState {
 }
 
 impl MacTextSystem {
-    pub(crate) fn new() -> Self {
+    /// Create a new MacTextSystem.
+    pub fn new() -> Self {
         Self(RwLock::new(MacTextSystemState {
             memory_source: MemSource::empty(),
             system_source: SystemSource::new(),

crates/gpui_macos/src/window.rs 🔗

@@ -172,6 +172,10 @@ unsafe fn build_classes() {
                     sel!(mouseExited:),
                     handle_view_event as extern "C" fn(&Object, Sel, id),
                 );
+                decl.add_method(
+                    sel!(magnifyWithEvent:),
+                    handle_view_event as extern "C" fn(&Object, Sel, id),
+                );
                 decl.add_method(
                     sel!(mouseDragged:),
                     handle_view_event as extern "C" fn(&Object, Sel, id),
@@ -2063,11 +2067,13 @@ fn update_window_scale_factor(window_state: &Arc<Mutex<MacWindowState>>) {
     let scale_factor = lock.scale_factor();
     let size = lock.content_size();
     let drawable_size = size.to_device_pixels(scale_factor);
-    unsafe {
-        let _: () = msg_send![
-            lock.renderer.layer(),
-            setContentsScale: scale_factor as f64
-        ];
+    if let Some(layer) = lock.renderer.layer() {
+        unsafe {
+            let _: () = msg_send![
+                layer,
+                setContentsScale: scale_factor as f64
+            ];
+        }
     }
 
     lock.renderer.update_drawable_size(drawable_size);

crates/gpui_macros/Cargo.toml 🔗

@@ -24,4 +24,4 @@ quote.workspace = true
 syn.workspace = true
 
 [dev-dependencies]
-gpui = { workspace = true, features = ["inspector"] }
+gpui = { workspace = true, features = ["inspector"] }

crates/gpui_macros/src/gpui_macros.rs 🔗

@@ -3,6 +3,7 @@ mod derive_app_context;
 mod derive_into_element;
 mod derive_render;
 mod derive_visual_context;
+mod property_test;
 mod register_action;
 mod styles;
 mod test;
@@ -188,6 +189,79 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
     test::test(args, function)
 }
 
+/// A variant of `#[gpui::test]` that supports property-based testing.
+///
+/// A property test, much like a standard GPUI randomized test, allows testing
+/// claims of the form "for any possible X, Y should hold". For example:
+/// ```
+/// #[gpui::property_test]
+/// fn test_arithmetic(x: i32, y: i32) {
+///     assert!(x == y || x < y || x > y);
+/// }
+/// ```
+/// Standard GPUI randomized tests provide you with an instance of `StdRng` to
+/// generate random data in a controlled manner. Property-based tests have some
+/// advantages, however:
+/// - Shrinking - the harness also understands a notion of the "complexity" of a
+///   particular value. This allows it to find the "simplest possible value that
+///   causes the test to fail".
+/// - Ergonomics/clarity - the property-testing harness will automatically
+///   generate values, removing the need to fill the test body with generation
+///   logic.
+/// - Failure persistence - if a failing seed is identified, it is stored in a
+///   file, which can be checked in, and future runs will check these cases before
+///   future cases.
+///
+/// Property tests work best when all inputs can be generated up-front and kept
+/// in a simple data structure. Sometimes, this isn't possible - for example, if
+/// a test needs to make a random decision based on the current state of some
+/// structure. In this case, a standard GPUI randomized test may be more
+/// suitable.
+///
+/// ## Customizing random values
+///
+/// This macro is based on the [`#[proptest::property_test]`] macro, but handles
+/// some of the same GPUI-specific arguments as `#[gpui::test]`. Specifically,
+/// `&{mut,} TestAppContext` and `BackgroundExecutor` work as normal. `StdRng`
+/// arguments are **explicitly forbidden**, since they break shrinking, and are
+/// a common footgun.
+///
+/// All other arguments are forwarded to the underlying proptest macro.
+///
+/// Note: much of the following is copied from the proptest docs, specifically the
+/// [`#[proptest::property_test]`] macro docs.
+///
+/// Random values of type `T` are generated by a `Strategy<Value = T>` object.
+/// Some types have a canonical `Strategy` - these types also implement
+/// `Arbitrary`. Parameters to a `#[gpui::property_test]`, by default, use a
+/// type's `Arbitrary` implementation. If you'd like to provide a custom
+/// strategy, you can use `#[strategy = ...]` on the argument:
+/// ```
+/// #[gpui::property_test]
+/// fn int_test(#[strategy = 1..10] x: i32, #[strategy = "[a-zA-Z0-9]{20}"] s: String) {
+///   assert!(s.len() > (x as usize));
+/// }
+/// ```
+///
+/// For more information on writing custom `Strategy` and `Arbitrary`
+/// implementations, see [the proptest book][book], and the [`Strategy`] trait.
+///
+/// ## Scheduler
+///
+/// Similar to `#[gpui::test]`, this macro will choose random seeds for the test
+/// scheduler. It uses `.no_shrink()` to tell proptest that all seeds are
+/// roughly equivalent in terms of "complexity". If `$SEED` is set, it will
+/// affect **ONLY** the seed passed to the scheduler. To control other values,
+/// use custom `Strategy`s.
+///
+/// [`#[proptest::property_test]`]: https://docs.rs/proptest/latest/proptest/attr.property_test.html
+/// [book]: https://proptest-rs.github.io/proptest/intro.html
+/// [`Strategy`]: https://docs.rs/proptest/latest/proptest/strategy/trait.Strategy.html
+#[proc_macro_attribute]
+pub fn property_test(args: TokenStream, function: TokenStream) -> TokenStream {
+    property_test::test(args.into(), function.into()).into()
+}
+
 /// When added to a trait, `#[derive_inspector_reflection]` generates a module which provides
 /// enumeration and lookup by name of all methods that have the shape `fn method(self) -> Self`.
 /// This is used by the inspector so that it can use the builder methods in `Styled` and

crates/gpui_macros/src/property_test.rs 🔗

@@ -0,0 +1,199 @@
+use proc_macro2::TokenStream;
+use quote::{format_ident, quote, quote_spanned};
+use syn::{
+    FnArg, Ident, ItemFn, Type, parse2, punctuated::Punctuated, spanned::Spanned, token::Comma,
+};
+
+pub fn test(args: TokenStream, item: TokenStream) -> TokenStream {
+    let item_span = item.span();
+    let Ok(func) = parse2::<ItemFn>(item) else {
+        return quote_spanned! { item_span =>
+            compile_error!("#[gpui::property_test] must be placed on a function");
+        };
+    };
+
+    let test_name = func.sig.ident.clone();
+    let inner_fn_name = format_ident!("__{test_name}");
+
+    let parsed_args = parse_args(func.sig.inputs, &test_name);
+
+    let inner_body = func.block;
+    let inner_arg_decls = parsed_args.inner_fn_decl_args;
+    let asyncness = func.sig.asyncness;
+
+    let inner_fn = quote! {
+        let #inner_fn_name = #asyncness move |#inner_arg_decls| #inner_body;
+    };
+
+    let arg_errors = parsed_args.errors;
+    let proptest_args = parsed_args.proptest_args;
+    let inner_args = parsed_args.inner_fn_args;
+    let cx_vars = parsed_args.cx_vars;
+    let cx_teardowns = parsed_args.cx_teardowns;
+
+    let proptest_args = quote! {
+        #[strategy = ::gpui::seed_strategy()] __seed: u64,
+        #proptest_args
+    };
+
+    let run_test_body = match &asyncness {
+        None => quote! {
+            #cx_vars
+            #inner_fn_name(#inner_args);
+            #cx_teardowns
+        },
+        Some(_) => quote! {
+            let foreground_executor = gpui::ForegroundExecutor::new(std::sync::Arc::new(dispatcher.clone()));
+            #cx_vars
+            foreground_executor.block_test(#inner_fn_name(#inner_args));
+            #cx_teardowns
+        },
+    };
+
+    quote! {
+        #arg_errors
+
+        #[::gpui::proptest::property_test(proptest_path = "::gpui::proptest", #args)]
+        fn #test_name(#proptest_args) {
+            #inner_fn
+
+            ::gpui::run_test_once(
+                __seed,
+                Box::new(move |dispatcher| {
+                    #run_test_body
+                }),
+            )
+        }
+    }
+}
+
+#[derive(Default)]
+struct ParsedArgs {
+    cx_vars: TokenStream,
+    cx_teardowns: TokenStream,
+    proptest_args: TokenStream,
+    errors: TokenStream,
+
+    // exprs passed at the call-site
+    inner_fn_args: TokenStream,
+    // args in the declaration
+    inner_fn_decl_args: TokenStream,
+}
+
+fn parse_args(args: Punctuated<FnArg, Comma>, test_name: &Ident) -> ParsedArgs {
+    let mut parsed = ParsedArgs::default();
+    let mut args = args.into_iter().collect();
+
+    remove_cxs(&mut parsed, &mut args, test_name);
+    remove_std_rng(&mut parsed, &mut args);
+    remove_background_executor(&mut parsed, &mut args);
+
+    // all remaining args forwarded to proptest's macro
+    parsed.proptest_args = quote!( #(#args),* );
+
+    parsed
+}
+
+fn remove_cxs(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>, test_name: &Ident) {
+    let mut ix = 0;
+    args.retain_mut(|arg| {
+        if !is_test_cx(arg) {
+            return true;
+        }
+
+        let cx_varname = format_ident!("cx_{ix}");
+        ix += 1;
+
+        parsed.cx_vars.extend(quote!(
+            let mut #cx_varname = gpui::TestAppContext::build(
+                dispatcher.clone(),
+                Some(stringify!(#test_name)),
+            );
+        ));
+        parsed.cx_teardowns.extend(quote!(
+            dispatcher.run_until_parked();
+            #cx_varname.executor().forbid_parking();
+            #cx_varname.quit();
+            dispatcher.run_until_parked();
+        ));
+
+        parsed.inner_fn_decl_args.extend(quote!(#arg,));
+        parsed.inner_fn_args.extend(quote!(&mut #cx_varname,));
+
+        false
+    });
+}
+
+fn remove_std_rng(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
+    args.retain_mut(|arg| {
+        if !is_std_rng(arg) {
+            return true;
+        }
+
+        parsed.errors.extend(quote_spanned! { arg.span() =>
+            compile_error!("`StdRng` is not allowed in a property test. Consider implementing `Arbitrary`, or implementing a custom `Strategy`. https://altsysrq.github.io/proptest-book/proptest/tutorial/strategy-basics.html");
+        });
+
+        false
+    });
+}
+
+fn remove_background_executor(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
+    args.retain_mut(|arg| {
+        if !is_background_executor(arg) {
+            return true;
+        }
+
+        parsed.inner_fn_decl_args.extend(quote!(#arg,));
+        parsed
+            .inner_fn_args
+            .extend(quote!(gpui::BackgroundExecutor::new(std::sync::Arc::new(
+                dispatcher.clone()
+            )),));
+
+        false
+    });
+}
+
+// Matches `&TestAppContext` or `&foo::bar::baz::TestAppContext`
+fn is_test_cx(arg: &FnArg) -> bool {
+    let FnArg::Typed(arg) = arg else {
+        return false;
+    };
+
+    let Type::Reference(ty) = &*arg.ty else {
+        return false;
+    };
+
+    let Type::Path(ty) = &*ty.elem else {
+        return false;
+    };
+
+    ty.path
+        .segments
+        .last()
+        .is_some_and(|seg| seg.ident == "TestAppContext")
+}
+
+fn is_std_rng(arg: &FnArg) -> bool {
+    is_path_with_last_segment(arg, "StdRng")
+}
+
+fn is_background_executor(arg: &FnArg) -> bool {
+    is_path_with_last_segment(arg, "BackgroundExecutor")
+}
+
+fn is_path_with_last_segment(arg: &FnArg, last_segment: &str) -> bool {
+    let FnArg::Typed(arg) = arg else {
+        return false;
+    };
+
+    let Type::Path(ty) = &*arg.ty else {
+        return false;
+    };
+
+    ty.path
+        .segments
+        .last()
+        .is_some_and(|seg| seg.ident == last_segment)
+}

crates/gpui_platform/src/gpui_platform.rs 🔗

@@ -59,6 +59,22 @@ pub fn current_platform(headless: bool) -> Rc<dyn Platform> {
     }
 }
 
+/// Returns a new [`HeadlessRenderer`] for the current platform, if available.
+#[cfg(feature = "test-support")]
+pub fn current_headless_renderer() -> Option<Box<dyn gpui::PlatformHeadlessRenderer>> {
+    #[cfg(target_os = "macos")]
+    {
+        Some(Box::new(
+            gpui_macos::metal_renderer::MetalHeadlessRenderer::new(),
+        ))
+    }
+
+    #[cfg(not(target_os = "macos"))]
+    {
+        None
+    }
+}
+
 #[cfg(all(test, target_os = "macos"))]
 mod tests {
     use super::*;

crates/gpui_web/src/dispatcher.rs 🔗

@@ -184,10 +184,6 @@ impl WebDispatcher {
                                     }
                                 };
 
-                                if runnable.metadata().is_closed() {

-                                    continue;

-                                }

-

                                 runnable.run();
                             }
                         })
@@ -263,9 +259,7 @@ impl PlatformDispatcher for WebDispatcher {
         let millis = duration.as_millis().min(i32::MAX as u128) as i32;
         if self.on_main_thread() {
             let callback = Closure::once_into_js(move || {
-                if !runnable.metadata().is_closed() {

-                    runnable.run();

-                }

+                runnable.run();

             });
             self.browser_window
                 .set_timeout_with_callback_and_timeout_and_arguments_0(
@@ -300,15 +294,11 @@ impl PlatformDispatcher for WebDispatcher {
 fn execute_on_main_thread(window: &web_sys::Window, item: MainThreadItem) {
     match item {
         MainThreadItem::Runnable(runnable) => {
-            if !runnable.metadata().is_closed() {

-                runnable.run();

-            }

+            runnable.run();

         }
         MainThreadItem::Delayed { runnable, millis } => {
             let callback = Closure::once_into_js(move || {
-                if !runnable.metadata().is_closed() {

-                    runnable.run();

-                }

+                runnable.run();

             });
             window
                 .set_timeout_with_callback_and_timeout_and_arguments_0(
@@ -325,9 +315,7 @@ fn execute_on_main_thread(window: &web_sys::Window, item: MainThreadItem) {
 
 fn schedule_runnable(window: &web_sys::Window, runnable: RunnableVariant, priority: Priority) {
     let callback = Closure::once_into_js(move || {
-        if !runnable.metadata().is_closed() {

-            runnable.run();

-        }

+        runnable.run();

     });
     let callback: &js_sys::Function = callback.unchecked_ref();
 

crates/gpui_wgpu/src/gpui_wgpu.rs 🔗

@@ -4,6 +4,7 @@ mod wgpu_context;
 mod wgpu_renderer;
 
 pub use cosmic_text_system::*;
+pub use wgpu;
 pub use wgpu_atlas::*;
 pub use wgpu_context::*;
-pub use wgpu_renderer::*;
+pub use wgpu_renderer::{GpuContext, WgpuRenderer, WgpuSurfaceConfig};

crates/gpui_wgpu/src/wgpu_atlas.rs 🔗

@@ -65,6 +65,17 @@ impl WgpuAtlas {
             view: texture.view.clone(),
         }
     }
+
+    /// Handles device lost by clearing all textures and cached tiles.
+    /// The atlas will lazily recreate textures as needed on subsequent frames.
+    pub fn handle_device_lost(&self, device: Arc<wgpu::Device>, queue: Arc<wgpu::Queue>) {
+        let mut lock = self.0.lock();
+        lock.device = device;
+        lock.queue = queue;
+        lock.storage = WgpuAtlasStorage::default();
+        lock.tiles_by_key.clear();
+        lock.pending_uploads.clear();
+    }
 }
 
 impl PlatformAtlas for WgpuAtlas {

crates/gpui_wgpu/src/wgpu_context.rs 🔗

@@ -3,6 +3,7 @@ use anyhow::Context as _;
 #[cfg(not(target_family = "wasm"))]
 use gpui_util::ResultExt;
 use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
 
 pub struct WgpuContext {
     pub instance: wgpu::Instance,
@@ -10,9 +11,10 @@ pub struct WgpuContext {
     pub device: Arc<wgpu::Device>,
     pub queue: Arc<wgpu::Queue>,
     dual_source_blending: bool,
+    device_lost: Arc<AtomicBool>,
 }
 
-#[cfg(not(target_family = "wasm"))]
+#[derive(Clone, Copy)]
 pub struct CompositorGpuHint {
     pub vendor_id: u32,
     pub device_id: u32,
@@ -47,6 +49,17 @@ impl WgpuContext {
                 compositor_gpu.as_ref(),
             ))?;
 
+        let device_lost = Arc::new(AtomicBool::new(false));
+        device.set_device_lost_callback({
+            let device_lost = Arc::clone(&device_lost);
+            move |reason, message| {
+                log::error!("wgpu device lost: reason={reason:?}, message={message}");
+                if reason != wgpu::DeviceLostReason::Destroyed {
+                    device_lost.store(true, Ordering::Relaxed);
+                }
+            }
+        });
+
         log::info!(
             "Selected GPU adapter: {:?} ({:?})",
             adapter.get_info().name,
@@ -59,6 +72,7 @@ impl WgpuContext {
             device: Arc::new(device),
             queue: Arc::new(queue),
             dual_source_blending,
+            device_lost,
         })
     }
 
@@ -86,6 +100,7 @@ impl WgpuContext {
             adapter.get_info().backend
         );
 
+        let device_lost = Arc::new(AtomicBool::new(false));
         let (device, queue, dual_source_blending) = Self::create_device(&adapter).await?;
 
         Ok(Self {
@@ -94,6 +109,7 @@ impl WgpuContext {
             device: Arc::new(device),
             queue: Arc::new(queue),
             dual_source_blending,
+            device_lost,
         })
     }
 
@@ -320,6 +336,17 @@ impl WgpuContext {
     pub fn supports_dual_source_blending(&self) -> bool {
         self.dual_source_blending
     }
+
+    /// Returns true if the GPU device was lost (e.g., due to driver crash, suspend/resume).
+    /// When this returns true, the context should be recreated.
+    pub fn device_lost(&self) -> bool {
+        self.device_lost.load(Ordering::Relaxed)
+    }
+
+    /// Returns a clone of the device_lost flag for sharing with renderers.
+    pub(crate) fn device_lost_flag(&self) -> Arc<AtomicBool> {
+        Arc::clone(&self.device_lost)
+    }
 }
 
 #[cfg(not(target_family = "wasm"))]

crates/gpui_wgpu/src/wgpu_renderer.rs 🔗

@@ -1,6 +1,4 @@
-#[cfg(not(target_family = "wasm"))]
-use crate::CompositorGpuHint;
-use crate::{WgpuAtlas, WgpuContext};
+use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext};
 use bytemuck::{Pod, Zeroable};
 use gpui::{
     AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point,
@@ -10,7 +8,9 @@ use gpui::{
 use log::warn;
 #[cfg(not(target_family = "wasm"))]
 use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
+use std::cell::RefCell;
 use std::num::NonZeroU64;
+use std::rc::Rc;
 use std::sync::{Arc, Mutex};
 
 #[repr(C)]
@@ -93,28 +93,42 @@ struct WgpuBindGroupLayouts {
     surfaces: wgpu::BindGroupLayout,
 }
 
-pub struct WgpuRenderer {
+/// Shared GPU context reference, used to coordinate device recovery across multiple windows.
+pub type GpuContext = Rc<RefCell<Option<WgpuContext>>>;
+
+/// GPU resources that must be dropped together during device recovery.
+struct WgpuResources {
     device: Arc<wgpu::Device>,
     queue: Arc<wgpu::Queue>,
     surface: wgpu::Surface<'static>,
-    surface_config: wgpu::SurfaceConfiguration,
     pipelines: WgpuPipelines,
     bind_group_layouts: WgpuBindGroupLayouts,
-    atlas: Arc<WgpuAtlas>,
     atlas_sampler: wgpu::Sampler,
     globals_buffer: wgpu::Buffer,
-    path_globals_offset: u64,
-    gamma_offset: u64,
     globals_bind_group: wgpu::BindGroup,
     path_globals_bind_group: wgpu::BindGroup,
     instance_buffer: wgpu::Buffer,
-    instance_buffer_capacity: u64,
-    max_buffer_size: u64,
-    storage_buffer_alignment: u64,
     path_intermediate_texture: Option<wgpu::Texture>,
     path_intermediate_view: Option<wgpu::TextureView>,
     path_msaa_texture: Option<wgpu::Texture>,
     path_msaa_view: Option<wgpu::TextureView>,
+}
+
+pub struct WgpuRenderer {
+    /// Shared GPU context for device recovery coordination (unused on WASM).
+    #[allow(dead_code)]
+    context: Option<GpuContext>,
+    /// Compositor GPU hint for adapter selection (unused on WASM).
+    #[allow(dead_code)]
+    compositor_gpu: Option<CompositorGpuHint>,
+    resources: Option<WgpuResources>,
+    surface_config: wgpu::SurfaceConfiguration,
+    atlas: Arc<WgpuAtlas>,
+    path_globals_offset: u64,
+    gamma_offset: u64,
+    instance_buffer_capacity: u64,
+    max_buffer_size: u64,
+    storage_buffer_alignment: u64,
     rendering_params: RenderingParameters,
     dual_source_blending: bool,
     adapter_info: wgpu::AdapterInfo,
@@ -123,17 +137,34 @@ pub struct WgpuRenderer {
     max_texture_size: u32,
     last_error: Arc<Mutex<Option<String>>>,
     failed_frame_count: u32,
+    device_lost: std::sync::Arc<std::sync::atomic::AtomicBool>,
 }
 
 impl WgpuRenderer {
+    fn resources(&self) -> &WgpuResources {
+        self.resources
+            .as_ref()
+            .expect("GPU resources not available")
+    }
+
+    fn resources_mut(&mut self) -> &mut WgpuResources {
+        self.resources
+            .as_mut()
+            .expect("GPU resources not available")
+    }
+
     /// Creates a new WgpuRenderer from raw window handles.
     ///
+    /// The `gpu_context` is a shared reference that coordinates GPU context across
+    /// multiple windows. The first window to create a renderer will initialize the
+    /// context; subsequent windows will share it.
+    ///
     /// # Safety
     /// The caller must ensure that the window handle remains valid for the lifetime
     /// of the returned renderer.
     #[cfg(not(target_family = "wasm"))]
     pub fn new<W: HasWindowHandle + HasDisplayHandle>(
-        gpu_context: &mut Option<WgpuContext>,
+        gpu_context: GpuContext,
         window: &W,
         config: WgpuSurfaceConfig,
         compositor_gpu: Option<CompositorGpuHint>,
@@ -154,6 +185,7 @@ impl WgpuRenderer {
         // The surface must be created with the same instance that will be used for
         // adapter selection, otherwise wgpu will panic.
         let instance = gpu_context
+            .borrow()
             .as_ref()
             .map(|ctx| ctx.instance.clone())
             .unwrap_or_else(WgpuContext::instance);
@@ -167,15 +199,28 @@ impl WgpuRenderer {
                 .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))?
         };
 
-        let context = match gpu_context {
+        let mut ctx_ref = gpu_context.borrow_mut();
+        let context = match ctx_ref.as_mut() {
             Some(context) => {
                 context.check_compatible_with_surface(&surface)?;
                 context
             }
-            None => gpu_context.insert(WgpuContext::new(instance, &surface, compositor_gpu)?),
+            None => ctx_ref.insert(WgpuContext::new(instance, &surface, compositor_gpu)?),
         };
 
-        Self::new_with_surface(context, surface, config)
+        let atlas = Arc::new(WgpuAtlas::new(
+            Arc::clone(&context.device),
+            Arc::clone(&context.queue),
+        ));
+
+        Self::new_internal(
+            Some(Rc::clone(&gpu_context)),
+            context,
+            surface,
+            config,
+            compositor_gpu,
+            atlas,
+        )
     }
 
     #[cfg(target_family = "wasm")]
@@ -188,13 +233,22 @@ impl WgpuRenderer {
             .instance
             .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
             .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))?;
-        Self::new_with_surface(context, surface, config)
+
+        let atlas = Arc::new(WgpuAtlas::new(
+            Arc::clone(&context.device),
+            Arc::clone(&context.queue),
+        ));
+
+        Self::new_internal(None, context, surface, config, None, atlas)
     }
 
-    fn new_with_surface(
+    fn new_internal(
+        gpu_context: Option<GpuContext>,
         context: &WgpuContext,
         surface: wgpu::Surface<'static>,
         config: WgpuSurfaceConfig,
+        compositor_gpu: Option<CompositorGpuHint>,
+        atlas: Arc<WgpuAtlas>,
     ) -> anyhow::Result<Self> {
         let surface_caps = surface.get_capabilities(&context.adapter);
         let preferred_formats = [
@@ -289,7 +343,6 @@ impl WgpuRenderer {
             dual_source_blending,
         );
 
-        let atlas = Arc::new(WgpuAtlas::new(Arc::clone(&device), Arc::clone(&queue)));
         let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
             label: Some("atlas_sampler"),
             mag_filter: wgpu::FilterMode::Linear,
@@ -375,30 +428,36 @@ impl WgpuRenderer {
             *guard = Some(error.to_string());
         }));
 
-        Ok(Self {
+        let resources = WgpuResources {
             device,
             queue,
             surface,
-            surface_config,
             pipelines,
             bind_group_layouts,
-            atlas,
             atlas_sampler,
             globals_buffer,
-            path_globals_offset,
-            gamma_offset,
             globals_bind_group,
             path_globals_bind_group,
             instance_buffer,
-            instance_buffer_capacity: initial_instance_buffer_capacity,
-            max_buffer_size,
-            storage_buffer_alignment,
             // Defer intermediate texture creation to first draw call via ensure_intermediate_textures().
             // This avoids panics when the device/surface is in an invalid state during initialization.
             path_intermediate_texture: None,
             path_intermediate_view: None,
             path_msaa_texture: None,
             path_msaa_view: None,
+        };
+
+        Ok(Self {
+            context: gpu_context,
+            compositor_gpu,
+            resources: Some(resources),
+            surface_config,
+            atlas,
+            path_globals_offset,
+            gamma_offset,
+            instance_buffer_capacity: initial_instance_buffer_capacity,
+            max_buffer_size,
+            storage_buffer_alignment,
             rendering_params,
             dual_source_blending,
             adapter_info,
@@ -407,6 +466,7 @@ impl WgpuRenderer {
             max_texture_size,
             last_error,
             failed_frame_count: 0,
+            device_lost: context.device_lost_flag(),
         })
     }
 
@@ -855,8 +915,14 @@ impl WgpuRenderer {
                 );
             }
 
+            self.surface_config.width = clamped_width.max(1);
+            self.surface_config.height = clamped_height.max(1);
+            let surface_config = self.surface_config.clone();
+
+            let resources = self.resources_mut();
+
             // Wait for any in-flight GPU work to complete before destroying textures
-            if let Err(e) = self.device.poll(wgpu::PollType::Wait {
+            if let Err(e) = resources.device.poll(wgpu::PollType::Wait {
                 submission_index: None,
                 timeout: None,
             }) {
@@ -864,55 +930,53 @@ impl WgpuRenderer {
             }
 
             // Destroy old textures before allocating new ones to avoid GPU memory spikes
-            if let Some(ref texture) = self.path_intermediate_texture {
+            if let Some(ref texture) = resources.path_intermediate_texture {
                 texture.destroy();
             }
-            if let Some(ref texture) = self.path_msaa_texture {
+            if let Some(ref texture) = resources.path_msaa_texture {
                 texture.destroy();
             }
 
-            self.surface_config.width = clamped_width.max(1);
-            self.surface_config.height = clamped_height.max(1);
-            self.surface.configure(&self.device, &self.surface_config);
+            resources
+                .surface
+                .configure(&resources.device, &surface_config);
 
             // Invalidate intermediate textures - they will be lazily recreated
             // in draw() after we confirm the surface is healthy. This avoids
             // panics when the device/surface is in an invalid state during resize.
-            self.path_intermediate_texture = None;
-            self.path_intermediate_view = None;
-            self.path_msaa_texture = None;
-            self.path_msaa_view = None;
+            resources.path_intermediate_texture = None;
+            resources.path_intermediate_view = None;
+            resources.path_msaa_texture = None;
+            resources.path_msaa_view = None;
         }
     }
 
     fn ensure_intermediate_textures(&mut self) {
-        if self.path_intermediate_texture.is_some() {
+        if self.resources().path_intermediate_texture.is_some() {
             return;
         }
 
-        let (path_intermediate_texture, path_intermediate_view) = {
-            let (t, v) = Self::create_path_intermediate(
-                &self.device,
-                self.surface_config.format,
-                self.surface_config.width,
-                self.surface_config.height,
-            );
-            (Some(t), Some(v))
-        };
-        self.path_intermediate_texture = path_intermediate_texture;
-        self.path_intermediate_view = path_intermediate_view;
+        let format = self.surface_config.format;
+        let width = self.surface_config.width;
+        let height = self.surface_config.height;
+        let path_sample_count = self.rendering_params.path_sample_count;
+        let resources = self.resources_mut();
+
+        let (t, v) = Self::create_path_intermediate(&resources.device, format, width, height);
+        resources.path_intermediate_texture = Some(t);
+        resources.path_intermediate_view = Some(v);
 
         let (path_msaa_texture, path_msaa_view) = Self::create_msaa_if_needed(
-            &self.device,
-            self.surface_config.format,
-            self.surface_config.width,
-            self.surface_config.height,
-            self.rendering_params.path_sample_count,
+            &resources.device,
+            format,
+            width,
+            height,
+            path_sample_count,
         )
         .map(|(t, v)| (Some(t), Some(v)))
         .unwrap_or((None, None));
-        self.path_msaa_texture = path_msaa_texture;
-        self.path_msaa_view = path_msaa_view;
+        resources.path_msaa_texture = path_msaa_texture;
+        resources.path_msaa_view = path_msaa_view;
     }
 
     pub fn update_transparency(&mut self, transparent: bool) {
@@ -924,14 +988,20 @@ impl WgpuRenderer {
 
         if new_alpha_mode != self.surface_config.alpha_mode {
             self.surface_config.alpha_mode = new_alpha_mode;
-            self.surface.configure(&self.device, &self.surface_config);
-            self.pipelines = Self::create_pipelines(
-                &self.device,
-                &self.bind_group_layouts,
-                self.surface_config.format,
-                self.surface_config.alpha_mode,
-                self.rendering_params.path_sample_count,
-                self.dual_source_blending,
+            let surface_config = self.surface_config.clone();
+            let path_sample_count = self.rendering_params.path_sample_count;
+            let dual_source_blending = self.dual_source_blending;
+            let resources = self.resources_mut();
+            resources
+                .surface
+                .configure(&resources.device, &surface_config);
+            resources.pipelines = Self::create_pipelines(
+                &resources.device,
+                &resources.bind_group_layouts,
+                surface_config.format,
+                surface_config.alpha_mode,
+                path_sample_count,
+                dual_source_blending,
             );
         }
     }
@@ -982,14 +1052,20 @@ impl WgpuRenderer {
 
         self.atlas.before_frame();
 
-        let frame = match self.surface.get_current_texture() {
+        let texture_result = self.resources().surface.get_current_texture();
+        let frame = match texture_result {
             Ok(frame) => frame,
             Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
-                self.surface.configure(&self.device, &self.surface_config);
+                let surface_config = self.surface_config.clone();
+                let resources = self.resources_mut();
+                resources
+                    .surface
+                    .configure(&resources.device, &surface_config);
                 return;
             }
             Err(e) => {
-                log::error!("Failed to acquire surface texture: {e}");
+                *self.last_error.lock().unwrap() =
+                    Some(format!("Failed to acquire surface texture: {e}"));
                 return;
             }
         };
@@ -1028,28 +1104,35 @@ impl WgpuRenderer {
             ..globals
         };
 
-        self.queue
-            .write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
-        self.queue.write_buffer(
-            &self.globals_buffer,
-            self.path_globals_offset,
-            bytemuck::bytes_of(&path_globals),
-        );
-        self.queue.write_buffer(
-            &self.globals_buffer,
-            self.gamma_offset,
-            bytemuck::bytes_of(&gamma_params),
-        );
+        {
+            let resources = self.resources();
+            resources.queue.write_buffer(
+                &resources.globals_buffer,
+                0,
+                bytemuck::bytes_of(&globals),
+            );
+            resources.queue.write_buffer(
+                &resources.globals_buffer,
+                self.path_globals_offset,
+                bytemuck::bytes_of(&path_globals),
+            );
+            resources.queue.write_buffer(
+                &resources.globals_buffer,
+                self.gamma_offset,
+                bytemuck::bytes_of(&gamma_params),
+            );
+        }
 
         loop {
             let mut instance_offset: u64 = 0;
             let mut overflow = false;
 
-            let mut encoder = self
-                .device
-                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
-                    label: Some("main_encoder"),
-                });
+            let mut encoder =
+                self.resources()
+                    .device
+                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+                        label: Some("main_encoder"),
+                    });
 
             {
                 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
@@ -1169,7 +1252,9 @@ impl WgpuRenderer {
                 continue;
             }
 
-            self.queue.submit(std::iter::once(encoder.finish()));
+            self.resources()
+                .queue
+                .submit(std::iter::once(encoder.finish()));
             frame.present();
             return;
         }
@@ -1185,7 +1270,7 @@ impl WgpuRenderer {
         self.draw_instances(
             data,
             quads.len() as u32,
-            &self.pipelines.quads,
+            &self.resources().pipelines.quads,
             instance_offset,
             pass,
         )
@@ -1201,7 +1286,7 @@ impl WgpuRenderer {
         self.draw_instances(
             data,
             shadows.len() as u32,
-            &self.pipelines.shadows,
+            &self.resources().pipelines.shadows,
             instance_offset,
             pass,
         )
@@ -1217,7 +1302,7 @@ impl WgpuRenderer {
         self.draw_instances(
             data,
             underlines.len() as u32,
-            &self.pipelines.underlines,
+            &self.resources().pipelines.underlines,
             instance_offset,
             pass,
         )
@@ -1236,7 +1321,7 @@ impl WgpuRenderer {
             data,
             sprites.len() as u32,
             &tex_info.view,
-            &self.pipelines.mono_sprites,
+            &self.resources().pipelines.mono_sprites,
             instance_offset,
             pass,
         )
@@ -1251,11 +1336,12 @@ impl WgpuRenderer {
     ) -> bool {
         let tex_info = self.atlas.get_texture_info(texture_id);
         let data = unsafe { Self::instance_bytes(sprites) };
-        let pipeline = self
+        let resources = self.resources();
+        let pipeline = resources
             .pipelines
             .subpixel_sprites
             .as_ref()
-            .unwrap_or(&self.pipelines.mono_sprites);
+            .unwrap_or(&resources.pipelines.mono_sprites);
         self.draw_instances_with_texture(
             data,
             sprites.len() as u32,
@@ -1279,7 +1365,7 @@ impl WgpuRenderer {
             data,
             sprites.len() as u32,
             &tex_info.view,
-            &self.pipelines.poly_sprites,
+            &self.resources().pipelines.poly_sprites,
             instance_offset,
             pass,
         )
@@ -1299,16 +1385,19 @@ impl WgpuRenderer {
         let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else {
             return false;
         };
-        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: None,
-            layout: &self.bind_group_layouts.instances,
-            entries: &[wgpu::BindGroupEntry {
-                binding: 0,
-                resource: self.instance_binding(offset, size),
-            }],
-        });
+        let resources = self.resources();
+        let bind_group = resources
+            .device
+            .create_bind_group(&wgpu::BindGroupDescriptor {
+                label: None,
+                layout: &resources.bind_group_layouts.instances,
+                entries: &[wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: self.instance_binding(offset, size),
+                }],
+            });
         pass.set_pipeline(pipeline);
-        pass.set_bind_group(0, &self.globals_bind_group, &[]);
+        pass.set_bind_group(0, &resources.globals_bind_group, &[]);
         pass.set_bind_group(1, &bind_group, &[]);
         pass.draw(0..4, 0..instance_count);
         true
@@ -1329,26 +1418,29 @@ impl WgpuRenderer {
         let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else {
             return false;
         };
-        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: None,
-            layout: &self.bind_group_layouts.instances_with_texture,
-            entries: &[
-                wgpu::BindGroupEntry {
-                    binding: 0,
-                    resource: self.instance_binding(offset, size),
-                },
-                wgpu::BindGroupEntry {
-                    binding: 1,
-                    resource: wgpu::BindingResource::TextureView(texture_view),
-                },
-                wgpu::BindGroupEntry {
-                    binding: 2,
-                    resource: wgpu::BindingResource::Sampler(&self.atlas_sampler),
-                },
-            ],
-        });
+        let resources = self.resources();
+        let bind_group = resources
+            .device
+            .create_bind_group(&wgpu::BindGroupDescriptor {
+                label: None,
+                layout: &resources.bind_group_layouts.instances_with_texture,
+                entries: &[
+                    wgpu::BindGroupEntry {
+                        binding: 0,
+                        resource: self.instance_binding(offset, size),
+                    },
+                    wgpu::BindGroupEntry {
+                        binding: 1,
+                        resource: wgpu::BindingResource::TextureView(texture_view),
+                    },
+                    wgpu::BindGroupEntry {
+                        binding: 2,
+                        resource: wgpu::BindingResource::Sampler(&resources.atlas_sampler),
+                    },
+                ],
+            });
         pass.set_pipeline(pipeline);
-        pass.set_bind_group(0, &self.globals_bind_group, &[]);
+        pass.set_bind_group(0, &resources.globals_bind_group, &[]);
         pass.set_bind_group(1, &bind_group, &[]);
         pass.draw(0..4, 0..instance_count);
         true
@@ -1386,7 +1478,8 @@ impl WgpuRenderer {
             vec![PathSprite { bounds }]
         };
 
-        let Some(path_intermediate_view) = self.path_intermediate_view.as_ref() else {
+        let resources = self.resources();
+        let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else {
             return true;
         };
 
@@ -1395,7 +1488,7 @@ impl WgpuRenderer {
             sprite_data,
             sprites.len() as u32,
             path_intermediate_view,
-            &self.pipelines.paths,
+            &resources.pipelines.paths,
             instance_offset,
             pass,
         )
@@ -1429,20 +1522,23 @@ impl WgpuRenderer {
             return false;
         };
 
-        let data_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: Some("path_rasterization_bind_group"),
-            layout: &self.bind_group_layouts.instances,
-            entries: &[wgpu::BindGroupEntry {
-                binding: 0,
-                resource: self.instance_binding(vertex_offset, vertex_size),
-            }],
-        });
+        let resources = self.resources();
+        let data_bind_group = resources
+            .device
+            .create_bind_group(&wgpu::BindGroupDescriptor {
+                label: Some("path_rasterization_bind_group"),
+                layout: &resources.bind_group_layouts.instances,
+                entries: &[wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: self.instance_binding(vertex_offset, vertex_size),
+                }],
+            });
 
-        let Some(path_intermediate_view) = self.path_intermediate_view.as_ref() else {
+        let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else {
             return true;
         };
 
-        let (target_view, resolve_target) = if let Some(ref msaa_view) = self.path_msaa_view {
+        let (target_view, resolve_target) = if let Some(ref msaa_view) = resources.path_msaa_view {
             (msaa_view, Some(path_intermediate_view))
         } else {
             (path_intermediate_view, None)
@@ -1464,8 +1560,8 @@ impl WgpuRenderer {
                 ..Default::default()
             });
 
-            pass.set_pipeline(&self.pipelines.path_rasterization);
-            pass.set_bind_group(0, &self.path_globals_bind_group, &[]);
+            pass.set_pipeline(&resources.pipelines.path_rasterization);
+            pass.set_bind_group(0, &resources.path_globals_bind_group, &[]);
             pass.set_bind_group(1, &data_bind_group, &[]);
             pass.draw(0..vertices.len() as u32, 0..1);
         }
@@ -1476,7 +1572,8 @@ impl WgpuRenderer {
     fn grow_instance_buffer(&mut self) {
         let new_capacity = (self.instance_buffer_capacity * 2).min(self.max_buffer_size);
         log::info!("increased instance buffer size to {}", new_capacity);
-        self.instance_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
+        let resources = self.resources_mut();
+        resources.instance_buffer = resources.device.create_buffer(&wgpu::BufferDescriptor {
             label: Some("instance_buffer"),
             size: new_capacity,
             usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
@@ -1495,14 +1592,17 @@ impl WgpuRenderer {
         if offset + size > self.instance_buffer_capacity {
             return None;
         }
-        self.queue.write_buffer(&self.instance_buffer, offset, data);
+        let resources = self.resources();
+        resources
+            .queue
+            .write_buffer(&resources.instance_buffer, offset, data);
         *instance_offset = offset + size;
         Some((offset, NonZeroU64::new(size).expect("size is at least 16")))
     }
 
     fn instance_binding(&self, offset: u64, size: NonZeroU64) -> wgpu::BindingResource<'_> {
         wgpu::BindingResource::Buffer(wgpu::BufferBinding {
-            buffer: &self.instance_buffer,
+            buffer: &self.resources().instance_buffer,
             offset,
             size: Some(size),
         })
@@ -1511,6 +1611,97 @@ impl WgpuRenderer {
     pub fn destroy(&mut self) {
         // wgpu resources are automatically cleaned up when dropped
     }
+
+    /// Returns true if the GPU device was lost and recovery is needed.
+    pub fn device_lost(&self) -> bool {
+        self.device_lost.load(std::sync::atomic::Ordering::SeqCst)
+    }
+
+    /// Recovers from a lost GPU device by recreating the renderer with a new context.
+    ///
+    /// Call this after detecting `device_lost()` returns true.
+    ///
+    /// This method coordinates recovery across multiple windows:
+    /// - The first window to call this will recreate the shared context
+    /// - Subsequent windows will adopt the already-recovered context
+    #[cfg(not(target_family = "wasm"))]
+    pub fn recover(
+        &mut self,
+        raw_display_handle: raw_window_handle::RawDisplayHandle,
+        raw_window_handle: raw_window_handle::RawWindowHandle,
+    ) -> anyhow::Result<()> {
+        let gpu_context = self.context.as_ref().expect("recover requires gpu_context");
+
+        // Check if another window already recovered the context
+        let needs_new_context = gpu_context
+            .borrow()
+            .as_ref()
+            .is_none_or(|ctx| ctx.device_lost());
+
+        let surface = if needs_new_context {
+            log::warn!("GPU device lost, recreating context...");
+
+            // Drop old resources to release Arc<Device>/Arc<Queue> and GPU resources
+            self.resources = None;
+            *gpu_context.borrow_mut() = None;
+
+            // Wait for GPU driver to stabilize (350ms copied from windows :shrug:)
+            std::thread::sleep(std::time::Duration::from_millis(350));
+
+            let instance = WgpuContext::instance();
+            let surface = create_surface(&instance, raw_display_handle, raw_window_handle)?;
+            let new_context = WgpuContext::new(instance, &surface, self.compositor_gpu)?;
+            *gpu_context.borrow_mut() = Some(new_context);
+            surface
+        } else {
+            let ctx_ref = gpu_context.borrow();
+            let instance = &ctx_ref.as_ref().unwrap().instance;
+            create_surface(instance, raw_display_handle, raw_window_handle)?
+        };
+
+        let config = WgpuSurfaceConfig {
+            size: gpui::Size {
+                width: gpui::DevicePixels(self.surface_config.width as i32),
+                height: gpui::DevicePixels(self.surface_config.height as i32),
+            },
+            transparent: self.surface_config.alpha_mode != wgpu::CompositeAlphaMode::Opaque,
+        };
+        let gpu_context = Rc::clone(gpu_context);
+        let ctx_ref = gpu_context.borrow();
+        let context = ctx_ref.as_ref().expect("context should exist");
+
+        self.resources = None;
+        self.atlas
+            .handle_device_lost(Arc::clone(&context.device), Arc::clone(&context.queue));
+
+        *self = Self::new_internal(
+            Some(gpu_context.clone()),
+            context,
+            surface,
+            config,
+            self.compositor_gpu,
+            self.atlas.clone(),
+        )?;
+
+        log::info!("GPU recovery complete");
+        Ok(())
+    }
+}
+
+#[cfg(not(target_family = "wasm"))]
+fn create_surface(
+    instance: &wgpu::Instance,
+    raw_display_handle: raw_window_handle::RawDisplayHandle,
+    raw_window_handle: raw_window_handle::RawWindowHandle,
+) -> anyhow::Result<wgpu::Surface<'static>> {
+    unsafe {
+        instance
+            .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
+                raw_display_handle,
+                raw_window_handle,
+            })
+            .map_err(|e| anyhow::anyhow!("{e}"))
+    }
 }
 
 struct RenderingParameters {

crates/gpui_windows/src/dispatcher.rs 🔗

@@ -58,10 +58,6 @@ impl WindowsDispatcher {
             let mut task_wrapper = Some(runnable);
             WorkItemHandler::new(move |_| {
                 let runnable = task_wrapper.take().unwrap();
-                // Check if the executor that spawned this task was closed
-                if runnable.metadata().is_closed() {
-                    return Ok(());
-                }
                 Self::execute_runnable(runnable);
                 Ok(())
             })
@@ -75,10 +71,6 @@ impl WindowsDispatcher {
             let mut task_wrapper = Some(runnable);
             TimerElapsedHandler::new(move |_| {
                 let runnable = task_wrapper.take().unwrap();
-                // Check if the executor that spawned this task was closed
-                if runnable.metadata().is_closed() {
-                    return Ok(());
-                }
                 Self::execute_runnable(runnable);
                 Ok(())
             })

crates/gpui_windows/src/events.rs 🔗

@@ -593,33 +593,63 @@ impl WindowsWindowInner {
     }
 
     pub(crate) fn update_ime_position(&self, handle: HWND, caret_position: POINT) {
+        let Some(ctx) = ImeContext::get(handle) else {
+            return;
+        };
         unsafe {
-            let ctx = ImmGetContext(handle);
-            if ctx.is_invalid() {
-                return;
-            }
+            ImmSetCompositionWindow(
+                *ctx,
+                &COMPOSITIONFORM {
+                    dwStyle: CFS_POINT,
+                    ptCurrentPos: caret_position,
+                    ..Default::default()
+                },
+            )
+            .ok()
+            .log_err();
 
-            let config = COMPOSITIONFORM {
-                dwStyle: CFS_POINT,
-                ptCurrentPos: caret_position,
-                ..Default::default()
-            };
-            ImmSetCompositionWindow(ctx, &config).ok().log_err();
-            let config = CANDIDATEFORM {
-                dwStyle: CFS_CANDIDATEPOS,
-                ptCurrentPos: caret_position,
-                ..Default::default()
-            };
-            ImmSetCandidateWindow(ctx, &config).ok().log_err();
-            ImmReleaseContext(handle, ctx).ok().log_err();
+            ImmSetCandidateWindow(
+                *ctx,
+                &CANDIDATEFORM {
+                    dwStyle: CFS_CANDIDATEPOS,
+                    ptCurrentPos: caret_position,
+                    ..Default::default()
+                },
+            )
+            .ok()
+            .log_err();
+        }
+    }
+
+    fn update_ime_enabled(&self, handle: HWND) {
+        let ime_enabled = self
+            .with_input_handler(|input_handler| input_handler.query_accepts_text_input())
+            .unwrap_or(false);
+        if ime_enabled == self.state.ime_enabled.get() {
+            return;
+        }
+        self.state.ime_enabled.set(ime_enabled);
+        unsafe {
+            if ime_enabled {
+                ImmAssociateContextEx(handle, HIMC::default(), IACE_DEFAULT)
+                    .ok()
+                    .log_err();
+            } else {
+                if let Some(ctx) = ImeContext::get(handle) {
+                    ImmNotifyIME(*ctx, NI_COMPOSITIONSTR, CPS_COMPLETE, 0)
+                        .ok()
+                        .log_err();
+                }
+                ImmAssociateContextEx(handle, HIMC::default(), 0)
+                    .ok()
+                    .log_err();
+            }
         }
     }
 
     fn handle_ime_composition(&self, handle: HWND, lparam: LPARAM) -> Option<isize> {
-        let ctx = unsafe { ImmGetContext(handle) };
-        let result = self.handle_ime_composition_inner(ctx, lparam);
-        unsafe { ImmReleaseContext(handle, ctx).ok().log_err() };
-        result
+        let ctx = ImeContext::get(handle)?;
+        self.handle_ime_composition_inner(*ctx, lparam)
     }
 
     fn handle_ime_composition_inner(&self, ctx: HIMC, lparam: LPARAM) -> Option<isize> {
@@ -1123,6 +1153,7 @@ impl WindowsWindowInner {
         });
 
         self.state.callbacks.request_frame.set(Some(request_frame));
+        self.update_ime_enabled(handle);
         unsafe { ValidateRect(Some(handle), None).ok().log_err() };
 
         Some(0)
@@ -1205,6 +1236,36 @@ impl WindowsWindowInner {
     }
 }
 
+struct ImeContext {
+    hwnd: HWND,
+    himc: HIMC,
+}
+
+impl ImeContext {
+    fn get(hwnd: HWND) -> Option<Self> {
+        let himc = unsafe { ImmGetContext(hwnd) };
+        if himc.is_invalid() {
+            return None;
+        }
+        Some(Self { hwnd, himc })
+    }
+}
+
+impl std::ops::Deref for ImeContext {
+    type Target = HIMC;
+    fn deref(&self) -> &HIMC {
+        &self.himc
+    }
+}
+
+impl Drop for ImeContext {
+    fn drop(&mut self) {
+        unsafe {
+            ImmReleaseContext(self.hwnd, self.himc).ok().log_err();
+        }
+    }
+}
+
 fn handle_key_event<F>(
     wparam: WPARAM,
     lparam: LPARAM,

crates/gpui_windows/src/window.rs 🔗

@@ -52,6 +52,7 @@ pub struct WindowsWindowState {
 
     pub callbacks: Callbacks,
     pub input_handler: Cell<Option<PlatformInputHandler>>,
+    pub ime_enabled: Cell<bool>,
     pub pending_surrogate: Cell<Option<u16>>,
     pub last_reported_modifiers: Cell<Option<Modifiers>>,
     pub last_reported_capslock: Cell<Option<Capslock>>,
@@ -142,6 +143,7 @@ impl WindowsWindowState {
             min_size,
             callbacks,
             input_handler: Cell::new(input_handler),
+            ime_enabled: Cell::new(true),
             pending_surrogate: Cell::new(pending_surrogate),
             last_reported_modifiers: Cell::new(last_reported_modifiers),
             last_reported_capslock: Cell::new(last_reported_capslock),

crates/icons/src/icons.rs 🔗

@@ -27,6 +27,7 @@ pub enum IconName {
     AiVZero,
     AiXAi,
     AiZed,
+    Archive,
     ArrowCircle,
     ArrowDown,
     ArrowDown10,
@@ -147,6 +148,7 @@ pub enum IconName {
     GitBranchPlus,
     GitCommit,
     GitGraph,
+    GitMergeConflict,
     Github,
     Hash,
     HistoryRerun,
@@ -243,6 +245,10 @@ pub enum IconName {
     ThinkingModeOff,
     Thread,
     ThreadFromSummary,
+    ThreadsSidebarLeftClosed,
+    ThreadsSidebarLeftOpen,
+    ThreadsSidebarRightClosed,
+    ThreadsSidebarRightOpen,
     ThumbsDown,
     ThumbsUp,
     TodoComplete,
@@ -271,8 +277,6 @@ pub enum IconName {
     UserRoundPen,
     Warning,
     WholeWord,
-    WorkspaceNavClosed,
-    WorkspaceNavOpen,
     XCircle,
     XCircleFilled,
     ZedAgent,

crates/image_viewer/src/image_viewer.rs 🔗

@@ -6,12 +6,14 @@ use std::path::Path;
 use anyhow::Context as _;
 use editor::{EditorSettings, items::entry_git_aware_label_color};
 use file_icons::FileIcons;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use gpui::PinchEvent;
 use gpui::{
     AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter,
-    FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
-    LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
-    Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions,
-    checkerboard, div, img, point, px, size,
+    FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement,
+    IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
+    ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task,
+    WeakEntity, Window, actions, checkerboard, div, img, point, px, size,
 };
 use language::File as _;
 use persistence::IMAGE_VIEWER;
@@ -24,7 +26,7 @@ use workspace::{
     ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
     WorkspaceId, delete_unloaded_items,
     invalid_item_view::InvalidItemView,
-    item::{BreadcrumbText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams},
+    item::{HighlightedText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams},
 };
 
 pub use crate::image_info::*;
@@ -260,6 +262,12 @@ impl ImageView {
             cx.notify();
         }
     }
+
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context<Self>) {
+        let zoom_factor = 1.0 + event.delta;
+        self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
+    }
 }
 
 struct ImageContentElement {
@@ -522,15 +530,17 @@ impl Item for ImageView {
         }
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
-        let settings = ThemeSettings::get_global(cx);
+        let font = ThemeSettings::get_global(cx).buffer_font.clone();
 
-        Some(vec![BreadcrumbText {
-            text,
-            highlights: None,
-            font: Some(settings.buffer_font.clone()),
-        }])
+        Some((
+            vec![HighlightedText {
+                text: text.into(),
+                highlights: vec![],
+            }],
+            Some(font),
+        ))
     }
 
     fn can_split(&self) -> bool {
@@ -679,8 +689,9 @@ impl Render for ImageView {
             .size_full()
             .relative()
             .bg(cx.theme().colors().editor_background)
-            .child(
-                div()
+            .child({
+                #[cfg(any(target_os = "linux", target_os = "macos"))]
+                let container = div()
                     .id("image-container")
                     .size_full()
                     .overflow_hidden()
@@ -690,13 +701,34 @@ impl Render for ImageView {
                         gpui::CursorStyle::OpenHand
                     })
                     .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
+                    .on_pinch(cx.listener(Self::handle_pinch))
                     .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
                     .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
                     .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
                     .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
                     .on_mouse_move(cx.listener(Self::handle_mouse_move))
-                    .child(ImageContentElement::new(cx.entity())),
-            )
+                    .child(ImageContentElement::new(cx.entity()));
+
+                #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+                let container = div()
+                    .id("image-container")
+                    .size_full()
+                    .overflow_hidden()
+                    .cursor(if self.is_dragging() {
+                        gpui::CursorStyle::ClosedHand
+                    } else {
+                        gpui::CursorStyle::OpenHand
+                    })
+                    .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
+                    .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
+                    .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
+                    .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
+                    .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
+                    .on_mouse_move(cx.listener(Self::handle_mouse_move))
+                    .child(ImageContentElement::new(cx.entity()));
+
+                container
+            })
     }
 }
 

crates/journal/src/journal.rs 🔗

@@ -9,7 +9,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use workspace::{AppState, OpenVisible, Workspace};
+use workspace::{AppState, OpenResult, OpenVisible, Workspace};
 
 actions!(
     journal,
@@ -107,7 +107,10 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
         .spawn(cx, async move |cx| {
             let (journal_dir, entry_path) = create_entry.await?;
             let opened = if open_new_workspace {
-                let (new_workspace, _) = cx
+                let OpenResult {
+                    window: new_workspace,
+                    ..
+                } = cx
                     .update(|_window, cx| {
                         workspace::open_paths(
                             &[journal_dir],

crates/json_schema_store/src/json_schema_store.rs 🔗

@@ -67,25 +67,22 @@ pub fn init(cx: &mut App) {
     .detach();
 
     if let Some(extension_events) = extension::ExtensionEvents::try_global(cx) {
-        cx.subscribe(&extension_events, move |_, evt, cx| {
-            match evt {
-                extension::Event::ExtensionInstalled(_)
-                | extension::Event::ExtensionUninstalled(_)
-                | extension::Event::ConfigureExtensionRequested(_) => return,
-                extension::Event::ExtensionsInstalledChanged => {}
+        cx.subscribe(&extension_events, move |_, evt, cx| match evt {
+            extension::Event::ExtensionsInstalledChanged => {
+                cx.update_global::<SchemaStore, _>(|schema_store, cx| {
+                    schema_store.notify_schema_changed(ChangedSchemas::Settings, cx);
+                });
             }
-            cx.update_global::<SchemaStore, _>(|schema_store, cx| {
-                schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}settings"), cx);
-                schema_store
-                    .notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}project_settings"), cx);
-            });
+            extension::Event::ExtensionUninstalled(_)
+            | extension::Event::ExtensionInstalled(_)
+            | extension::Event::ConfigureExtensionRequested(_) => {}
         })
         .detach();
     }
 
     cx.observe_global::<dap::DapRegistry>(move |cx| {
         cx.update_global::<SchemaStore, _>(|schema_store, cx| {
-            schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}debug_tasks"), cx);
+            schema_store.notify_schema_changed(ChangedSchemas::DebugTasks, cx);
         });
     })
     .detach();
@@ -98,18 +95,42 @@ pub struct SchemaStore {
 
 impl gpui::Global for SchemaStore {}
 
+enum ChangedSchemas {
+    Settings,
+    DebugTasks,
+}
+
 impl SchemaStore {
-    fn notify_schema_changed(&mut self, uri: &str, cx: &mut App) {
-        DYNAMIC_SCHEMA_CACHE.write().remove(uri);
+    fn notify_schema_changed(&mut self, changed_schemas: ChangedSchemas, cx: &mut App) {
+        let uris_to_invalidate = match changed_schemas {
+            ChangedSchemas::Settings => {
+                let settings_uri_prefix = &format!("{SCHEMA_URI_PREFIX}settings");
+                let project_settings_uri = &format!("{SCHEMA_URI_PREFIX}project_settings");
+                DYNAMIC_SCHEMA_CACHE
+                    .write()
+                    .extract_if(|uri, _| {
+                        uri == project_settings_uri || uri.starts_with(settings_uri_prefix)
+                    })
+                    .map(|(url, _)| url)
+                    .collect()
+            }
+            ChangedSchemas::DebugTasks => DYNAMIC_SCHEMA_CACHE
+                .write()
+                .remove_entry(&format!("{SCHEMA_URI_PREFIX}debug_tasks"))
+                .map_or_else(Vec::new, |(uri, _)| vec![uri]),
+        };
+
+        if uris_to_invalidate.is_empty() {
+            return;
+        }
 
-        let uri = uri.to_string();
         self.lsp_stores.retain(|lsp_store| {
             let Some(lsp_store) = lsp_store.upgrade() else {
                 return false;
             };
-            project::lsp_store::json_language_server_ext::notify_schema_changed(
+            project::lsp_store::json_language_server_ext::notify_schemas_changed(
                 lsp_store,
-                uri.clone(),
+                &uris_to_invalidate,
                 cx,
             );
             true
@@ -238,7 +259,8 @@ async fn resolve_dynamic_schema(
                 (adapter_name, LspSchemaKind::Settings)
             } else {
                 anyhow::bail!(
-                    "Invalid LSP schema path: expected '{{adapter}}/initialization_options' or '{{adapter}}/settings', got '{}'",
+                    "Invalid LSP schema path: \
+                    Expected '{{adapter}}/initialization_options' or '{{adapter}}/settings', got '{}'",
                     lsp_path
                 );
             };
@@ -484,7 +506,7 @@ pub fn all_schema_file_associations(
             let file_name = normalized_action_name_to_file_name(normalized_name.clone());
             serde_json::json!({
                 "fileMatch": [file_name],
-                "url": format!("{}action/{normalized_name}", SCHEMA_URI_PREFIX)
+                "url": format!("{SCHEMA_URI_PREFIX}action/{normalized_name}")
             })
         }));
 

crates/keymap_editor/src/keymap_editor.rs 🔗

@@ -2928,9 +2928,11 @@ impl Render for KeybindingEditorModal {
                                                 .child(
                                                     Button::new("show_matching", "View")
                                                         .label_size(LabelSize::Small)
-                                                        .icon(IconName::ArrowUpRight)
-                                                        .icon_color(Color::Muted)
-                                                        .icon_size(IconSize::Small)
+                                                        .end_icon(
+                                                            Icon::new(IconName::ArrowUpRight)
+                                                                .size(IconSize::Small)
+                                                                .color(Color::Muted),
+                                                        )
                                                         .on_click(cx.listener(
                                                             |this, _, window, cx| {
                                                                 this.show_matching_bindings(

crates/language/Cargo.toml 🔗

@@ -62,6 +62,7 @@ sum_tree.workspace = true
 task.workspace = true
 text.workspace = true
 theme.workspace = true
+toml.workspace = true
 tracing.workspace = true
 tree-sitter-md = { workspace = true, optional = true }
 tree-sitter-python = { workspace = true, optional = true }

crates/language/src/buffer.rs 🔗

@@ -359,7 +359,7 @@ pub enum BufferEvent {
         is_local: bool,
     },
     /// The buffer was edited.
-    Edited,
+    Edited { is_local: bool },
     /// The buffer's `dirty` bit changed.
     DirtyChanged,
     /// The buffer was saved.
@@ -435,7 +435,7 @@ pub enum DiskState {
     /// File created in Zed that has not been saved.
     New,
     /// File present on the filesystem.
-    Present { mtime: MTime },
+    Present { mtime: MTime, size: u64 },
     /// Deleted file that was previously present.
     Deleted,
     /// An old version of a file that was previously present
@@ -448,7 +448,17 @@ impl DiskState {
     pub fn mtime(self) -> Option<MTime> {
         match self {
             DiskState::New => None,
-            DiskState::Present { mtime } => Some(mtime),
+            DiskState::Present { mtime, .. } => Some(mtime),
+            DiskState::Deleted => None,
+            DiskState::Historic { .. } => None,
+        }
+    }
+
+    /// Returns the file's size on disk in bytes.
+    pub fn size(self) -> Option<u64> {
+        match self {
+            DiskState::New => None,
+            DiskState::Present { size, .. } => Some(size),
             DiskState::Deleted => None,
             DiskState::Historic { .. } => None,
         }
@@ -2377,7 +2387,7 @@ impl Buffer {
         };
         match file.disk_state() {
             DiskState::New => false,
-            DiskState::Present { mtime } => match self.saved_mtime {
+            DiskState::Present { mtime, .. } => match self.saved_mtime {
                 Some(saved_mtime) => {
                     mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits()
                 }
@@ -2457,7 +2467,7 @@ impl Buffer {
             false
         };
         if let Some((transaction_id, start_version)) = self.text.end_transaction_at(now) {
-            self.did_edit(&start_version, was_dirty, cx);
+            self.did_edit(&start_version, was_dirty, true, cx);
             Some(transaction_id)
         } else {
             None
@@ -2844,7 +2854,13 @@ impl Buffer {
         Some(edit_id)
     }
 
-    fn did_edit(&mut self, old_version: &clock::Global, was_dirty: bool, cx: &mut Context<Self>) {
+    fn did_edit(
+        &mut self,
+        old_version: &clock::Global,
+        was_dirty: bool,
+        is_local: bool,
+        cx: &mut Context<Self>,
+    ) {
         self.was_changed();
 
         if self.edits_since::<usize>(old_version).next().is_none() {
@@ -2852,10 +2868,20 @@ impl Buffer {
         }
 
         self.reparse(cx, true);
-        cx.emit(BufferEvent::Edited);
-        if was_dirty != self.is_dirty() {
+        cx.emit(BufferEvent::Edited { is_local });
+        let is_dirty = self.is_dirty();
+        if was_dirty != is_dirty {
             cx.emit(BufferEvent::DirtyChanged);
         }
+        if was_dirty && !is_dirty {
+            if let Some(file) = self.file.as_ref() {
+                if matches!(file.disk_state(), DiskState::Present { .. })
+                    && file.disk_state().mtime() != self.saved_mtime
+                {
+                    cx.emit(BufferEvent::ReloadNeeded);
+                }
+            }
+        }
         cx.notify();
     }
 
@@ -2964,7 +2990,7 @@ impl Buffer {
         self.text.apply_ops(buffer_ops);
         self.deferred_ops.insert(deferred_ops);
         self.flush_deferred_ops(cx);
-        self.did_edit(&old_version, was_dirty, cx);
+        self.did_edit(&old_version, was_dirty, false, cx);
         // Notify independently of whether the buffer was edited as the operations could include a
         // selection update.
         cx.notify();
@@ -3119,7 +3145,7 @@ impl Buffer {
 
         if let Some((transaction_id, operation)) = self.text.undo() {
             self.send_operation(Operation::Buffer(operation), true, cx);
-            self.did_edit(&old_version, was_dirty, cx);
+            self.did_edit(&old_version, was_dirty, true, cx);
             self.restore_encoding_for_transaction(transaction_id, was_dirty);
             Some(transaction_id)
         } else {
@@ -3137,7 +3163,7 @@ impl Buffer {
         let old_version = self.version.clone();
         if let Some(operation) = self.text.undo_transaction(transaction_id) {
             self.send_operation(Operation::Buffer(operation), true, cx);
-            self.did_edit(&old_version, was_dirty, cx);
+            self.did_edit(&old_version, was_dirty, true, cx);
             true
         } else {
             false
@@ -3159,7 +3185,7 @@ impl Buffer {
             self.send_operation(Operation::Buffer(operation), true, cx);
         }
         if undone {
-            self.did_edit(&old_version, was_dirty, cx)
+            self.did_edit(&old_version, was_dirty, true, cx)
         }
         undone
     }
@@ -3169,7 +3195,7 @@ impl Buffer {
         let operation = self.text.undo_operations(counts);
         let old_version = self.version.clone();
         self.send_operation(Operation::Buffer(operation), true, cx);
-        self.did_edit(&old_version, was_dirty, cx);
+        self.did_edit(&old_version, was_dirty, true, cx);
     }
 
     /// Manually redoes a specific transaction in the buffer's redo history.
@@ -3179,7 +3205,7 @@ impl Buffer {
 
         if let Some((transaction_id, operation)) = self.text.redo() {
             self.send_operation(Operation::Buffer(operation), true, cx);
-            self.did_edit(&old_version, was_dirty, cx);
+            self.did_edit(&old_version, was_dirty, true, cx);
             self.restore_encoding_for_transaction(transaction_id, was_dirty);
             Some(transaction_id)
         } else {
@@ -3220,7 +3246,7 @@ impl Buffer {
             self.send_operation(Operation::Buffer(operation), true, cx);
         }
         if redone {
-            self.did_edit(&old_version, was_dirty, cx)
+            self.did_edit(&old_version, was_dirty, true, cx)
         }
         redone
     }
@@ -3330,7 +3356,7 @@ impl Buffer {
         if !ops.is_empty() {
             for op in ops {
                 self.send_operation(Operation::Buffer(op), true, cx);
-                self.did_edit(&old_version, was_dirty, cx);
+                self.did_edit(&old_version, was_dirty, true, cx);
             }
         }
     }

crates/language/src/buffer_tests.rs 🔗

@@ -458,15 +458,18 @@ fn test_edit_events(cx: &mut gpui::App) {
     assert_eq!(
         mem::take(&mut *buffer_1_events.lock()),
         vec![
-            BufferEvent::Edited,
+            BufferEvent::Edited { is_local: true },
             BufferEvent::DirtyChanged,
-            BufferEvent::Edited,
-            BufferEvent::Edited,
+            BufferEvent::Edited { is_local: true },
+            BufferEvent::Edited { is_local: true },
         ]
     );
     assert_eq!(
         mem::take(&mut *buffer_2_events.lock()),
-        vec![BufferEvent::Edited, BufferEvent::DirtyChanged]
+        vec![
+            BufferEvent::Edited { is_local: false },
+            BufferEvent::DirtyChanged
+        ]
     );
 
     buffer1.update(cx, |buffer, cx| {
@@ -481,11 +484,17 @@ fn test_edit_events(cx: &mut gpui::App) {
     });
     assert_eq!(
         mem::take(&mut *buffer_1_events.lock()),
-        vec![BufferEvent::Edited, BufferEvent::DirtyChanged,]
+        vec![
+            BufferEvent::Edited { is_local: true },
+            BufferEvent::DirtyChanged,
+        ]
     );
     assert_eq!(
         mem::take(&mut *buffer_2_events.lock()),
-        vec![BufferEvent::Edited, BufferEvent::DirtyChanged]
+        vec![
+            BufferEvent::Edited { is_local: false },
+            BufferEvent::DirtyChanged
+        ]
     );
 }
 

crates/language/src/language.rs 🔗

@@ -961,6 +961,15 @@ pub struct LanguageConfig {
     pub import_path_strip_regex: Option<Regex>,
 }
 
+impl LanguageConfig {
+    pub const FILE_NAME: &str = "config.toml";
+
+    pub fn load(config_path: impl AsRef<Path>) -> Result<Self> {
+        let config = std::fs::read_to_string(config_path.as_ref())?;
+        toml::from_str(&config).map_err(Into::into)
+    }
+}
+
 #[derive(Clone, Debug, Deserialize, Default, JsonSchema)]
 pub struct DecreaseIndentConfig {
     #[serde(default, deserialize_with = "deserialize_regex")]

crates/language_extension/src/extension_lsp_adapter.rs 🔗

@@ -350,6 +350,44 @@ impl LspAdapter for ExtensionLspAdapter {
         })
     }
 
+    async fn initialization_options_schema(
+        self: Arc<Self>,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        _cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+        _cx: &mut AsyncApp,
+    ) -> Option<serde_json::Value> {
+        let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
+        let json_schema: Option<String> = self
+            .extension
+            .language_server_initialization_options_schema(
+                self.language_server_id.clone(),
+                delegate,
+            )
+            .await
+            .ok()
+            .flatten();
+        json_schema.and_then(|s| serde_json::from_str(&s).ok())
+    }
+
+    async fn settings_schema(
+        self: Arc<Self>,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        _cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+        _cx: &mut AsyncApp,
+    ) -> Option<serde_json::Value> {
+        let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
+        let json_schema: Option<String> = self
+            .extension
+            .language_server_workspace_configuration_schema(
+                self.language_server_id.clone(),
+                delegate,
+            )
+            .await
+            .ok()
+            .flatten();
+        json_schema.and_then(|s| serde_json::from_str(&s).ok())
+    }
+
     async fn additional_initialization_options(
         self: Arc<Self>,
         target_language_server_id: LanguageServerName,

crates/language_model/src/language_model.rs 🔗

@@ -13,10 +13,11 @@ pub mod fake_provider;
 use anthropic::{AnthropicError, parse_prompt_too_long};
 use anyhow::{Result, anyhow};
 use client::Client;
+use client::UserStore;
 use cloud_llm_client::CompletionRequestStatus;
 use futures::FutureExt;
 use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window};
+use gpui::{AnyView, App, AsyncApp, Entity, SharedString, Task, Window};
 use http_client::{StatusCode, http};
 use icons::IconName;
 use open_router::OpenRouterError;
@@ -61,9 +62,9 @@ pub const ZED_CLOUD_PROVIDER_ID: LanguageModelProviderId = LanguageModelProvider
 pub const ZED_CLOUD_PROVIDER_NAME: LanguageModelProviderName =
     LanguageModelProviderName::new("Zed");
 
-pub fn init(client: Arc<Client>, cx: &mut App) {
+pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
     init_settings(cx);
-    RefreshLlmTokenListener::register(client, cx);
+    RefreshLlmTokenListener::register(client, user_store, cx);
 }
 
 pub fn init_settings(cx: &mut App) {

crates/language_model/src/model/cloud_model.rs 🔗

@@ -3,11 +3,14 @@ use std::sync::Arc;
 
 use anyhow::{Context as _, Result};
 use client::Client;
+use client::UserStore;
 use cloud_api_client::ClientApiError;
 use cloud_api_types::OrganizationId;
 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 _};
+use gpui::{
+    App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
+};
 use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard};
 use thiserror::Error;
 
@@ -27,6 +30,13 @@ impl fmt::Display for PaymentRequiredError {
 pub struct LlmApiToken(Arc<RwLock<Option<String>>>);
 
 impl LlmApiToken {
+    pub fn global(cx: &App) -> Self {
+        RefreshLlmTokenListener::global(cx)
+            .read(cx)
+            .llm_api_token
+            .clone()
+    }
+
     pub async fn acquire(
         &self,
         client: &Arc<Client>,
@@ -99,15 +109,20 @@ struct GlobalRefreshLlmTokenListener(Entity<RefreshLlmTokenListener>);
 
 impl Global for GlobalRefreshLlmTokenListener {}
 
-pub struct RefreshLlmTokenEvent;
+pub struct LlmTokenRefreshedEvent;
 
-pub struct RefreshLlmTokenListener;
+pub struct RefreshLlmTokenListener {
+    client: Arc<Client>,
+    user_store: Entity<UserStore>,
+    llm_api_token: LlmApiToken,
+    _subscription: Subscription,
+}
 
-impl EventEmitter<RefreshLlmTokenEvent> for RefreshLlmTokenListener {}
+impl EventEmitter<LlmTokenRefreshedEvent> for RefreshLlmTokenListener {}
 
 impl RefreshLlmTokenListener {
-    pub fn register(client: Arc<Client>, cx: &mut App) {
-        let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, cx));
+    pub fn register(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
+        let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx));
         cx.set_global(GlobalRefreshLlmTokenListener(listener));
     }
 
@@ -115,7 +130,7 @@ impl RefreshLlmTokenListener {
         GlobalRefreshLlmTokenListener::global(cx).0.clone()
     }
 
-    fn new(client: Arc<Client>, cx: &mut Context<Self>) -> Self {
+    fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
         client.add_message_to_client_handler({
             let this = cx.entity();
             move |message, cx| {
@@ -123,13 +138,39 @@ impl RefreshLlmTokenListener {
             }
         });
 
-        Self
+        let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| {
+            if matches!(event, client::user::Event::OrganizationChanged) {
+                this.refresh(cx);
+            }
+        });
+
+        Self {
+            client,
+            user_store,
+            llm_api_token: LlmApiToken::default(),
+            _subscription: subscription,
+        }
+    }
+
+    fn refresh(&self, cx: &mut Context<Self>) {
+        let client = self.client.clone();
+        let llm_api_token = self.llm_api_token.clone();
+        let organization_id = self
+            .user_store
+            .read(cx)
+            .current_organization()
+            .map(|organization| organization.id.clone());
+        cx.spawn(async move |this, cx| {
+            llm_api_token.refresh(&client, organization_id).await?;
+            this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent))
+        })
+        .detach_and_log_err(cx);
     }
 
     fn handle_refresh_llm_token(this: Entity<Self>, message: &MessageToClient, cx: &mut App) {
         match message {
             MessageToClient::UserUpdated => {
-                this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent));
+                this.update(cx, |this, cx| this.refresh(cx));
             }
         }
     }

crates/language_models/Cargo.toml 🔗

@@ -68,7 +68,7 @@ vercel = { workspace = true, features = ["schemars"] }
 x_ai = { workspace = true, features = ["schemars"] }
 
 [dev-dependencies]
-editor = { workspace = true, features = ["test-support"] }
+
 language_model = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
-project = { workspace = true, features = ["test-support"] }
+

crates/language_models/src/provider/bedrock.rs 🔗

@@ -1611,7 +1611,8 @@ impl Render for ConfigurationView {
         }
 
         v_flex()
-            .size_full()
+            .min_w_0()
+            .w_full()
             .track_focus(&self.focus_handle)
             .on_action(cx.listener(Self::on_tab))
             .on_action(cx.listener(Self::on_tab_prev))

crates/language_models/src/provider/cloud.rs 🔗

@@ -109,9 +109,10 @@ impl State {
         cx: &mut Context<Self>,
     ) -> Self {
         let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
+        let llm_api_token = LlmApiToken::global(cx);
         Self {
             client: client.clone(),
-            llm_api_token: LlmApiToken::default(),
+            llm_api_token,
             user_store: user_store.clone(),
             status,
             models: Vec::new(),
@@ -156,11 +157,8 @@ impl State {
                         .user_store
                         .read(cx)
                         .current_organization()
-                        .map(|o| o.id.clone());
+                        .map(|organization| organization.id.clone());
                     cx.spawn(async move |this, cx| {
-                        llm_api_token
-                            .refresh(&client, organization_id.clone())
-                            .await?;
                         let response =
                             Self::fetch_models(client, llm_api_token, organization_id).await?;
                         this.update(cx, |this, cx| {
@@ -707,7 +705,7 @@ impl LanguageModel for CloudLanguageModel {
                     .user_store
                     .read(cx)
                     .current_organization()
-                    .map(|o| o.id.clone());
+                    .map(|organization| organization.id.clone());
                 let model_id = self.model.id.to_string();
                 let generate_content_request =
                     into_google(request, model_id.clone(), GoogleModelMode::Default);
@@ -779,7 +777,7 @@ impl LanguageModel for CloudLanguageModel {
             user_store
                 .read(cx)
                 .current_organization()
-                .map(|o| o.id.clone())
+                .map(|organization| organization.id.clone())
         });
         let thinking_allowed = request.thinking_allowed;
         let enable_thinking = thinking_allowed && self.model.supports_thinking;
@@ -866,7 +864,10 @@ impl LanguageModel for CloudLanguageModel {
                 );
 
                 if enable_thinking && let Some(effort) = effort {
-                    request.reasoning = Some(open_ai::responses::ReasoningConfig { effort });
+                    request.reasoning = Some(open_ai::responses::ReasoningConfig {
+                        effort,
+                        summary: Some(open_ai::responses::ReasoningSummaryMode::Auto),
+                    });
                 }
 
                 let future = self.request_limiter.stream(async move {
@@ -1125,6 +1126,7 @@ impl RenderOnce for ZedAiConfiguration {
         let manage_subscription_buttons = if is_pro {
             Button::new("manage_settings", "Manage Subscription")
                 .full_width()
+                .label_size(LabelSize::Small)
                 .style(ButtonStyle::Tinted(TintColor::Accent))
                 .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))
                 .into_any_element()
@@ -1148,10 +1150,7 @@ impl RenderOnce for ZedAiConfiguration {
                 .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models."))
                 .child(
                     Button::new("sign_in", "Sign In to use Zed AI")
-                        .icon_color(Color::Muted)
-                        .icon(IconName::Github)
-                        .icon_size(IconSize::Small)
-                        .icon_position(IconPosition::Start)
+                        .start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted))
                         .full_width()
                         .on_click({
                             let callback = self.sign_in_callback.clone();

crates/language_models/src/provider/copilot_chat.rs 🔗

@@ -2,15 +2,17 @@ use std::pin::Pin;
 use std::str::FromStr as _;
 use std::sync::Arc;
 
+use anthropic::AnthropicModelMode;
 use anyhow::{Result, anyhow};
 use cloud_llm_client::CompletionIntent;
 use collections::HashMap;
 use copilot::{GlobalCopilotAuth, Status};
 use copilot_chat::responses as copilot_responses;
 use copilot_chat::{
-    ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, CopilotChatConfiguration,
-    Function, FunctionContent, ImageUrl, Model as CopilotChatModel, ModelVendor,
-    Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent, ToolChoice,
+    ChatLocation, ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat,
+    CopilotChatConfiguration, Function, FunctionContent, ImageUrl, Model as CopilotChatModel,
+    ModelVendor, Request as CopilotChatRequest, ResponseEvent, Tool, ToolCall, ToolCallContent,
+    ToolChoice,
 };
 use futures::future::BoxFuture;
 use futures::stream::BoxStream;
@@ -20,8 +22,8 @@ use http_client::StatusCode;
 use language::language_settings::all_language_settings;
 use language_model::{
     AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelId, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelCompletionEvent, LanguageModelCostInfo, LanguageModelEffortLevel, LanguageModelId,
+    LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage,
     LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
     LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
@@ -30,6 +32,7 @@ use settings::SettingsStore;
 use ui::prelude::*;
 use util::debug_panic;
 
+use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic};
 use crate::provider::util::parse_tool_arguments;
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat");
@@ -254,6 +257,33 @@ impl LanguageModel for CopilotChatLanguageModel {
         self.model.supports_vision()
     }
 
+    fn supports_thinking(&self) -> bool {
+        self.model.can_think()
+    }
+
+    fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
+        let levels = self.model.reasoning_effort_levels();
+        if levels.is_empty() {
+            return vec![];
+        }
+        levels
+            .iter()
+            .map(|level| {
+                let name: SharedString = match level.as_str() {
+                    "low" => "Low".into(),
+                    "medium" => "Medium".into(),
+                    "high" => "High".into(),
+                    _ => SharedString::from(level.clone()),
+                };
+                LanguageModelEffortLevel {
+                    name,
+                    value: SharedString::from(level.clone()),
+                    is_default: level == "high",
+                }
+            })
+            .collect()
+    }
+
     fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
         match self.model.vendor() {
             ModelVendor::OpenAI | ModelVendor::Anthropic => {
@@ -333,12 +363,94 @@ impl LanguageModel for CopilotChatLanguageModel {
             | CompletionIntent::EditFile => false,
         });
 
+        if self.model.supports_messages() {
+            let location = intent_to_chat_location(request.intent);
+            let model = self.model.clone();
+            let request_limiter = self.request_limiter.clone();
+            let future = cx.spawn(async move |cx| {
+                let effort = request
+                    .thinking_effort
+                    .as_ref()
+                    .and_then(|e| anthropic::Effort::from_str(e).ok());
+
+                let mut anthropic_request = into_anthropic(
+                    request,
+                    model.id().to_string(),
+                    0.0,
+                    model.max_output_tokens() as u64,
+                    if model.supports_adaptive_thinking() {
+                        AnthropicModelMode::Thinking {
+                            budget_tokens: None,
+                        }
+                    } else if model.can_think() {
+                        AnthropicModelMode::Thinking {
+                            budget_tokens: compute_thinking_budget(
+                                model.min_thinking_budget(),
+                                model.max_thinking_budget(),
+                                model.max_output_tokens() as u32,
+                            ),
+                        }
+                    } else {
+                        AnthropicModelMode::Default
+                    },
+                );
+
+                anthropic_request.temperature = None;
+
+                // The Copilot proxy doesn't support eager_input_streaming on tools.
+                for tool in &mut anthropic_request.tools {
+                    tool.eager_input_streaming = false;
+                }
+
+                if model.supports_adaptive_thinking() {
+                    if anthropic_request.thinking.is_some() {
+                        anthropic_request.thinking = Some(anthropic::Thinking::Adaptive);
+                        anthropic_request.output_config = Some(anthropic::OutputConfig { effort });
+                    }
+                }
+
+                let anthropic_beta = if !model.supports_adaptive_thinking() && model.can_think() {
+                    Some("interleaved-thinking-2025-05-14".to_string())
+                } else {
+                    None
+                };
+
+                let body = serde_json::to_string(&anthropic::StreamingRequest {
+                    base: anthropic_request,
+                    stream: true,
+                })
+                .map_err(|e| anyhow::anyhow!(e))?;
+
+                let stream = CopilotChat::stream_messages(
+                    body,
+                    location,
+                    is_user_initiated,
+                    anthropic_beta,
+                    cx.clone(),
+                );
+
+                request_limiter
+                    .stream(async move {
+                        let events = stream.await?;
+                        let mapper = AnthropicEventMapper::new();
+                        Ok(mapper.map_stream(events).boxed())
+                    })
+                    .await
+            });
+            return async move { Ok(future.await?.boxed()) }.boxed();
+        }
+
         if self.model.supports_response() {
+            let location = intent_to_chat_location(request.intent);
             let responses_request = into_copilot_responses(&self.model, request);
             let request_limiter = self.request_limiter.clone();
             let future = cx.spawn(async move |cx| {
-                let request =
-                    CopilotChat::stream_response(responses_request, is_user_initiated, cx.clone());
+                let request = CopilotChat::stream_response(
+                    responses_request,
+                    location,
+                    is_user_initiated,
+                    cx.clone(),
+                );
                 request_limiter
                     .stream(async move {
                         let stream = request.await?;
@@ -350,6 +462,7 @@ impl LanguageModel for CopilotChatLanguageModel {
             return async move { Ok(future.await?.boxed()) }.boxed();
         }
 
+        let location = intent_to_chat_location(request.intent);
         let copilot_request = match into_copilot_chat(&self.model, request) {
             Ok(request) => request,
             Err(err) => return futures::future::ready(Err(err.into())).boxed(),
@@ -358,8 +471,12 @@ impl LanguageModel for CopilotChatLanguageModel {
 
         let request_limiter = self.request_limiter.clone();
         let future = cx.spawn(async move |cx| {
-            let request =
-                CopilotChat::stream_completion(copilot_request, is_user_initiated, cx.clone());
+            let request = CopilotChat::stream_completion(
+                copilot_request,
+                location,
+                is_user_initiated,
+                cx.clone(),
+            );
             request_limiter
                 .stream(async move {
                     let response = request.await?;
@@ -761,6 +878,9 @@ fn into_copilot_chat(
     model: &CopilotChatModel,
     request: LanguageModelRequest,
 ) -> Result<CopilotChatRequest> {
+    let temperature = request.temperature;
+    let tool_choice = request.tool_choice;
+
     let mut request_messages: Vec<LanguageModelRequestMessage> = Vec::new();
     for message in request.messages {
         if let Some(last_message) = request_messages.last_mut() {
@@ -859,10 +979,9 @@ fn into_copilot_chat(
                 let text_content = {
                     let mut buffer = String::new();
                     for string in message.content.iter().filter_map(|content| match content {
-                        MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
-                            Some(text.as_str())
-                        }
-                        MessageContent::ToolUse(_)
+                        MessageContent::Text(text) => Some(text.as_str()),
+                        MessageContent::Thinking { .. }
+                        | MessageContent::ToolUse(_)
                         | MessageContent::RedactedThinking(_)
                         | MessageContent::ToolResult(_)
                         | MessageContent::Image(_) => None,
@@ -919,21 +1038,52 @@ fn into_copilot_chat(
         .collect::<Vec<_>>();
 
     Ok(CopilotChatRequest {
-        intent: true,
         n: 1,
         stream: model.uses_streaming(),
-        temperature: 0.1,
+        temperature: temperature.unwrap_or(0.1),
         model: model.id().to_string(),
         messages,
         tools,
-        tool_choice: request.tool_choice.map(|choice| match choice {
+        tool_choice: tool_choice.map(|choice| match choice {
             LanguageModelToolChoice::Auto => ToolChoice::Auto,
             LanguageModelToolChoice::Any => ToolChoice::Any,
             LanguageModelToolChoice::None => ToolChoice::None,
         }),
+        thinking_budget: None,
     })
 }
 
+fn compute_thinking_budget(
+    min_budget: Option<u32>,
+    max_budget: Option<u32>,
+    max_output_tokens: u32,
+) -> Option<u32> {
+    let configured_budget: u32 = 16000;
+    let min_budget = min_budget.unwrap_or(1024);
+    let max_budget = max_budget.unwrap_or(max_output_tokens.saturating_sub(1));
+    let normalized = configured_budget.max(min_budget);
+    Some(
+        normalized
+            .min(max_budget)
+            .min(max_output_tokens.saturating_sub(1)),
+    )
+}
+
+fn intent_to_chat_location(intent: Option<CompletionIntent>) -> ChatLocation {
+    match intent {
+        Some(CompletionIntent::UserPrompt) => ChatLocation::Agent,
+        Some(CompletionIntent::ToolResults) => ChatLocation::Agent,
+        Some(CompletionIntent::ThreadSummarization) => ChatLocation::Panel,
+        Some(CompletionIntent::ThreadContextSummarization) => ChatLocation::Panel,
+        Some(CompletionIntent::CreateFile) => ChatLocation::Agent,
+        Some(CompletionIntent::EditFile) => ChatLocation::Agent,
+        Some(CompletionIntent::InlineAssist) => ChatLocation::Editor,
+        Some(CompletionIntent::TerminalInlineAssist) => ChatLocation::Terminal,
+        Some(CompletionIntent::GenerateGitCommitMessage) => ChatLocation::Other,
+        None => ChatLocation::Panel,
+    }
+}
+
 fn into_copilot_responses(
     model: &CopilotChatModel,
     request: LanguageModelRequest,
@@ -949,7 +1099,7 @@ fn into_copilot_responses(
         tool_choice,
         stop: _,
         temperature,
-        thinking_allowed: _,
+        thinking_allowed,
         thinking_effort: _,
         speed: _,
     } = request;
@@ -1128,10 +1278,18 @@ fn into_copilot_responses(
         temperature,
         tools: converted_tools,
         tool_choice: mapped_tool_choice,
-        reasoning: None, // We would need to add support for setting from user settings.
+        reasoning: if thinking_allowed {
+            Some(copilot_responses::ReasoningConfig {
+                effort: copilot_responses::ReasoningEffort::Medium,
+                summary: Some(copilot_responses::ReasoningSummary::Detailed),
+            })
+        } else {
+            None
+        },
         include: Some(vec![
             copilot_responses::ResponseIncludable::ReasoningEncryptedContent,
         ]),
+        store: false,
     }
 }
 

crates/language_models/src/provider/lmstudio.rs 🔗

@@ -1,26 +1,30 @@
 use anyhow::{Result, anyhow};
 use collections::HashMap;
+use fs::Fs;
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task};
+use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Subscription, Task};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
-    StopReason, TokenUsage,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolResultContent,
+    LanguageModelToolUse, MessageContent, StopReason, TokenUsage, env_var,
 };
 use language_model::{
-    IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, RateLimiter, Role,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, Role,
 };
-use lmstudio::{ModelType, get_models};
+use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models};
+
 pub use settings::LmStudioAvailableModel as AvailableModel;
-use settings::{Settings, SettingsStore};
+use settings::{Settings, SettingsStore, update_settings_file};
 use std::pin::Pin;
+use std::sync::LazyLock;
 use std::{collections::BTreeMap, sync::Arc};
-use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*};
-use util::ResultExt;
+use ui::{
+    ButtonLike, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, prelude::*,
+};
+use ui_input::InputField;
 
 use crate::AllLanguageModelSettings;
 use crate::provider::util::parse_tool_arguments;
@@ -32,6 +36,9 @@ const LMSTUDIO_SITE: &str = "https://lmstudio.ai/";
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("lmstudio");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("LM Studio");
 
+const API_KEY_ENV_VAR_NAME: &str = "LMSTUDIO_API_KEY";
+static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
+
 #[derive(Default, Debug, Clone, PartialEq)]
 pub struct LmStudioSettings {
     pub api_url: String,
@@ -44,6 +51,7 @@ pub struct LmStudioLanguageModelProvider {
 }
 
 pub struct State {
+    api_key_state: ApiKeyState,
     http_client: Arc<dyn HttpClient>,
     available_models: Vec<lmstudio::Model>,
     fetch_model_task: Option<Task<Result<()>>>,
@@ -55,14 +63,25 @@ impl State {
         !self.available_models.is_empty()
     }
 
+    fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let api_url = LmStudioLanguageModelProvider::api_url(cx).into();
+        let task = self
+            .api_key_state
+            .store(api_url, api_key, |this| &mut this.api_key_state, cx);
+        self.restart_fetch_models_task(cx);
+        task
+    }
+
     fn fetch_models(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
         let settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
         let http_client = self.http_client.clone();
         let api_url = settings.api_url.clone();
+        let api_key = self.api_key_state.key(&api_url);
 
         // As a proxy for the server being "authenticated", we'll check if its up by fetching the models
         cx.spawn(async move |this, cx| {
-            let models = get_models(http_client.as_ref(), &api_url, None).await?;
+            let models =
+                get_models(http_client.as_ref(), &api_url, api_key.as_deref(), None).await?;
 
             let mut models: Vec<lmstudio::Model> = models
                 .into_iter()
@@ -95,6 +114,11 @@ impl State {
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let api_url = LmStudioLanguageModelProvider::api_url(cx).into();
+        let _task = self
+            .api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
+
         if self.is_authenticated() {
             return Task::ready(Ok(()));
         }
@@ -145,6 +169,10 @@ impl LmStudioLanguageModelProvider {
                 });
 
                 State {
+                    api_key_state: ApiKeyState::new(
+                        Self::api_url(cx).into(),
+                        (*API_KEY_ENV_VAR).clone(),
+                    ),
                     http_client,
                     available_models: Default::default(),
                     fetch_model_task: None,
@@ -156,6 +184,17 @@ impl LmStudioLanguageModelProvider {
             .update(cx, |state, cx| state.restart_fetch_models_task(cx));
         this
     }
+
+    fn api_url(cx: &App) -> String {
+        AllLanguageModelSettings::get_global(cx)
+            .lmstudio
+            .api_url
+            .clone()
+    }
+
+    fn has_custom_url(cx: &App) -> bool {
+        Self::api_url(cx) != LMSTUDIO_API_URL
+    }
 }
 
 impl LanguageModelProviderState for LmStudioLanguageModelProvider {
@@ -225,6 +264,7 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
                     model,
                     http_client: self.http_client.clone(),
                     request_limiter: RateLimiter::new(4),
+                    state: self.state.clone(),
                 }) as Arc<dyn LanguageModel>
             })
             .collect()
@@ -244,12 +284,13 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
         _window: &mut Window,
         cx: &mut App,
     ) -> AnyView {
-        let state = self.state.clone();
-        cx.new(|cx| ConfigurationView::new(state, cx)).into()
+        cx.new(|cx| ConfigurationView::new(self.state.clone(), _window, cx))
+            .into()
     }
 
     fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
-        self.state.update(cx, |state, cx| state.fetch_models(cx))
+        self.state
+            .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 }
 
@@ -258,6 +299,7 @@ pub struct LmStudioLanguageModel {
     model: lmstudio::Model,
     http_client: Arc<dyn HttpClient>,
     request_limiter: RateLimiter,
+    state: Entity<State>,
 }
 
 impl LmStudioLanguageModel {
@@ -376,15 +418,20 @@ impl LmStudioLanguageModel {
         Result<futures::stream::BoxStream<'static, Result<lmstudio::ResponseStreamEvent>>>,
     > {
         let http_client = self.http_client.clone();
-        let api_url = cx.update(|cx| {
-            let settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
-            settings.api_url.clone()
+        let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
+            let api_url = LmStudioLanguageModelProvider::api_url(cx);
+            (state.api_key_state.key(&api_url), api_url)
         });
 
         let future = self.request_limiter.stream(async move {
-            let request = lmstudio::stream_chat_completion(http_client.as_ref(), &api_url, request);
-            let response = request.await?;
-            Ok(response)
+            let stream = lmstudio::stream_chat_completion(
+                http_client.as_ref(),
+                &api_url,
+                api_key.as_deref(),
+                request,
+            )
+            .await?;
+            Ok(stream)
         });
 
         async move { Ok(future.await?.boxed()) }.boxed()
@@ -634,53 +681,210 @@ fn add_message_content_part(
 
 struct ConfigurationView {
     state: Entity<State>,
-    loading_models_task: Option<Task<()>>,
+    api_key_editor: Entity<InputField>,
+    api_url_editor: Entity<InputField>,
 }
 
 impl ConfigurationView {
-    pub fn new(state: Entity<State>, cx: &mut Context<Self>) -> Self {
-        let loading_models_task = Some(cx.spawn({
-            let state = state.clone();
-            async move |this, cx| {
-                state
-                    .update(cx, |state, cx| state.authenticate(cx))
-                    .await
-                    .log_err();
-
-                this.update(cx, |this, cx| {
-                    this.loading_models_task = None;
-                    cx.notify();
-                })
-                .log_err();
-            }
-        }));
+    pub fn new(state: Entity<State>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let api_key_editor = cx.new(|cx| InputField::new(_window, cx, "sk-...").label("API key"));
+
+        let api_url_editor = cx.new(|cx| {
+            let input = InputField::new(_window, cx, LMSTUDIO_API_URL).label("API URL");
+            input.set_text(&LmStudioLanguageModelProvider::api_url(cx), _window, cx);
+            input
+        });
+
+        cx.observe(&state, |_, _, cx| {
+            cx.notify();
+        })
+        .detach();
 
         Self {
             state,
-            loading_models_task,
+            api_key_editor,
+            api_url_editor,
+        }
+    }
+
+    fn retry_connection(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        let has_api_url = LmStudioLanguageModelProvider::has_custom_url(cx);
+        let has_api_key = self
+            .state
+            .read_with(cx, |state, _| state.api_key_state.has_key());
+        if !has_api_url {
+            self.save_api_url(cx);
+        }
+        if !has_api_key {
+            self.save_api_key(&Default::default(), _window, cx);
+        }
+
+        self.state.update(cx, |state, cx| {
+            state.restart_fetch_models_task(cx);
+        });
+    }
+
+    fn save_api_key(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+        let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
+        if api_key.is_empty() {
+            return;
+        }
+
+        self.api_key_editor
+            .update(cx, |input, cx| input.set_text("", _window, cx));
+
+        let state = self.state.clone();
+        cx.spawn_in(_window, async move |_, cx| {
+            state
+                .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
+                .await
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn reset_api_key(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.api_key_editor
+            .update(cx, |input, cx| input.set_text("", _window, cx));
+
+        let state = self.state.clone();
+        cx.spawn_in(_window, async move |_, cx| {
+            state
+                .update(cx, |state, cx| state.set_api_key(None, cx))
+                .await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
+    }
+
+    fn save_api_url(&self, cx: &mut Context<Self>) {
+        let api_url = self.api_url_editor.read(cx).text(cx).trim().to_string();
+        let current_url = LmStudioLanguageModelProvider::api_url(cx);
+        if !api_url.is_empty() && &api_url != &current_url {
+            self.state
+                .update(cx, |state, cx| state.set_api_key(None, cx))
+                .detach_and_log_err(cx);
+
+            let fs = <dyn Fs>::global(cx);
+            update_settings_file(fs, cx, move |settings, _| {
+                settings
+                    .language_models
+                    .get_or_insert_default()
+                    .lmstudio
+                    .get_or_insert_default()
+                    .api_url = Some(api_url);
+            });
         }
     }
 
-    fn retry_connection(&self, cx: &mut App) {
+    fn reset_api_url(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.api_url_editor
+            .update(cx, |input, cx| input.set_text("", _window, cx));
+
+        // Clear API key when URL changes since keys are URL-specific
         self.state
-            .update(cx, |state, cx| state.fetch_models(cx))
+            .update(cx, |state, cx| state.set_api_key(None, cx))
             .detach_and_log_err(cx);
-    }
-}
 
-impl Render for ConfigurationView {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let is_authenticated = self.state.read(cx).is_authenticated();
+        let fs = <dyn Fs>::global(cx);
+        update_settings_file(fs, cx, |settings, _cx| {
+            if let Some(settings) = settings
+                .language_models
+                .as_mut()
+                .and_then(|models| models.lmstudio.as_mut())
+            {
+                settings.api_url = Some(LMSTUDIO_API_URL.into());
+            }
+        });
+        cx.notify();
+    }
 
-        let lmstudio_intro = "Run local LLMs like Llama, Phi, and Qwen.";
+    fn render_api_url_editor(&self, cx: &Context<Self>) -> impl IntoElement {
+        let api_url = LmStudioLanguageModelProvider::api_url(cx);
+        let custom_api_url_set = api_url != LMSTUDIO_API_URL;
 
-        if self.loading_models_task.is_some() {
-            div().child(Label::new("Loading models...")).into_any()
+        if custom_api_url_set {
+            h_flex()
+                .p_3()
+                .justify_between()
+                .rounded_md()
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .bg(cx.theme().colors().elevated_surface_background)
+                .child(
+                    h_flex()
+                        .gap_2()
+                        .child(Icon::new(IconName::Check).color(Color::Success))
+                        .child(v_flex().gap_1().child(Label::new(api_url))),
+                )
+                .child(
+                    Button::new("reset-api-url", "Reset API URL")
+                        .label_size(LabelSize::Small)
+                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
+                        .layer(ElevationIndex::ModalSurface)
+                        .on_click(
+                            cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)),
+                        ),
+                )
+                .into_any_element()
         } else {
             v_flex()
+                .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
+                    this.save_api_url(cx);
+                    cx.notify();
+                }))
                 .gap_2()
+                .child(self.api_url_editor.clone())
+                .into_any_element()
+        }
+    }
+
+    fn render_api_key_editor(&self, cx: &Context<Self>) -> impl IntoElement {
+        let state = self.state.read(cx);
+        let env_var_set = state.api_key_state.is_from_env_var();
+        let configured_card_label = if env_var_set {
+            format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable.")
+        } else {
+            "API key configured".to_string()
+        };
+
+        if !state.api_key_state.has_key() {
+            v_flex()
+                .on_action(cx.listener(Self::save_api_key))
+                .child(self.api_key_editor.clone())
                 .child(
-                    v_flex().gap_1().child(Label::new(lmstudio_intro)).child(
+                    Label::new(format!(
+                        "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
+                    ))
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+                )
+                .into_any_element()
+        } else {
+            ConfiguredApiCard::new(configured_card_label)
+                .disabled(env_var_set)
+                .on_click(cx.listener(|this, _, _window, cx| this.reset_api_key(_window, cx)))
+                .when(env_var_set, |this| {
+                    this.tooltip_label(format!(
+                        "To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."
+                    ))
+                })
+                .into_any_element()
+        }
+    }
+}
+
+impl Render for ConfigurationView {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_authenticated = self.state.read(cx).is_authenticated();
+
+        v_flex()
+            .gap_2()
+            .child(
+                v_flex()
+                    .gap_1()
+                    .child(Label::new("Run local LLMs like Llama, Phi, and Qwen."))
+                    .child(
                         List::new()
                             .child(ListBulletItem::new(
                                 "LM Studio needs to be running with at least one model downloaded.",
@@ -690,86 +894,106 @@ impl Render for ConfigurationView {
                                     .child(Label::new("To get your first model, try running"))
                                     .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)),
                             ),
-                    ),
-                )
-                .child(
-                    h_flex()
-                        .w_full()
-                        .justify_between()
-                        .gap_2()
-                        .child(
-                            h_flex()
-                                .w_full()
-                                .gap_2()
-                                .map(|this| {
-                                    if is_authenticated {
-                                        this.child(
-                                            Button::new("lmstudio-site", "LM Studio")
-                                                .style(ButtonStyle::Subtle)
-                                                .icon(IconName::ArrowUpRight)
-                                                .icon_size(IconSize::Small)
-                                                .icon_color(Color::Muted)
-                                                .on_click(move |_, _window, cx| {
-                                                    cx.open_url(LMSTUDIO_SITE)
-                                                })
-                                                .into_any_element(),
-                                        )
-                                    } else {
-                                        this.child(
-                                            Button::new(
-                                                "download_lmstudio_button",
-                                                "Download LM Studio",
-                                            )
+                    )
+                    .child(Label::new(
+                        "Alternatively, you can connect to an LM Studio server by specifying its \
+                        URL and API key (may not be required):",
+                    )),
+            )
+            .child(self.render_api_url_editor(cx))
+            .child(self.render_api_key_editor(cx))
+            .child(
+                h_flex()
+                    .w_full()
+                    .justify_between()
+                    .gap_2()
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .gap_2()
+                            .map(|this| {
+                                if is_authenticated {
+                                    this.child(
+                                        Button::new("lmstudio-site", "LM Studio")
                                             .style(ButtonStyle::Subtle)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::Small)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(move |_, _window, cx| {
-                                                cx.open_url(LMSTUDIO_DOWNLOAD_URL)
+                                                cx.open_url(LMSTUDIO_SITE)
                                             })
                                             .into_any_element(),
+                                    )
+                                } else {
+                                    this.child(
+                                        Button::new(
+                                            "download_lmstudio_button",
+                                            "Download LM Studio",
                                         )
-                                    }
-                                })
-                                .child(
-                                    Button::new("view-models", "Model Catalog")
                                         .style(ButtonStyle::Subtle)
-                                        .icon(IconName::ArrowUpRight)
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(Color::Muted)
+                                        .end_icon(
+                                            Icon::new(IconName::ArrowUpRight)
+                                                .size(IconSize::Small)
+                                                .color(Color::Muted),
+                                        )
                                         .on_click(move |_, _window, cx| {
-                                            cx.open_url(LMSTUDIO_CATALOG_URL)
-                                        }),
-                                ),
-                        )
-                        .map(|this| {
-                            if is_authenticated {
-                                this.child(
-                                    ButtonLike::new("connected")
-                                        .disabled(true)
-                                        .cursor_style(gpui::CursorStyle::Arrow)
-                                        .child(
-                                            h_flex()
-                                                .gap_2()
-                                                .child(Indicator::dot().color(Color::Success))
-                                                .child(Label::new("Connected"))
-                                                .into_any_element(),
-                                        ),
-                                )
-                            } else {
-                                this.child(
-                                    Button::new("retry_lmstudio_models", "Connect")
-                                        .icon_position(IconPosition::Start)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon(IconName::PlayFilled)
-                                        .on_click(cx.listener(move |this, _, _window, cx| {
-                                            this.retry_connection(cx)
-                                        })),
-                                )
-                            }
-                        }),
-                )
-                .into_any()
-        }
+                                            cx.open_url(LMSTUDIO_DOWNLOAD_URL)
+                                        })
+                                        .into_any_element(),
+                                    )
+                                }
+                            })
+                            .child(
+                                Button::new("view-models", "Model Catalog")
+                                    .style(ButtonStyle::Subtle)
+                                    .end_icon(
+                                        Icon::new(IconName::ArrowUpRight)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                    .on_click(move |_, _window, cx| {
+                                        cx.open_url(LMSTUDIO_CATALOG_URL)
+                                    }),
+                            ),
+                    )
+                    .map(|this| {
+                        if is_authenticated {
+                            this.child(
+                                ButtonLike::new("connected")
+                                    .disabled(true)
+                                    .cursor_style(CursorStyle::Arrow)
+                                    .child(
+                                        h_flex()
+                                            .gap_2()
+                                            .child(Icon::new(IconName::Check).color(Color::Success))
+                                            .child(Label::new("Connected"))
+                                            .into_any_element(),
+                                    )
+                                    .child(
+                                        IconButton::new("refresh-models", IconName::RotateCcw)
+                                            .tooltip(Tooltip::text("Refresh Models"))
+                                            .on_click(cx.listener(|this, _, _window, cx| {
+                                                this.state.update(cx, |state, _| {
+                                                    state.available_models.clear();
+                                                });
+                                                this.retry_connection(_window, cx);
+                                            })),
+                                    ),
+                            )
+                        } else {
+                            this.child(
+                                Button::new("retry_lmstudio_models", "Connect")
+                                    .start_icon(
+                                        Icon::new(IconName::PlayFilled).size(IconSize::XSmall),
+                                    )
+                                    .on_click(cx.listener(move |this, _, _window, cx| {
+                                        this.retry_connection(_window, cx)
+                                    })),
+                            )
+                        }
+                    }),
+            )
     }
 }

crates/language_models/src/provider/ollama.rs 🔗

@@ -858,9 +858,7 @@ impl ConfigurationView {
                 .child(
                     Button::new("reset-context-window", "Reset")
                         .label_size(LabelSize::Small)
-                        .icon(IconName::Undo)
-                        .icon_size(IconSize::Small)
-                        .icon_position(IconPosition::Start)
+                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
                         .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| {
@@ -905,9 +903,7 @@ impl ConfigurationView {
                 .child(
                     Button::new("reset-api-url", "Reset API URL")
                         .label_size(LabelSize::Small)
-                        .icon(IconName::Undo)
-                        .icon_size(IconSize::Small)
-                        .icon_position(IconPosition::Start)
+                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
                         .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)),
@@ -949,9 +945,11 @@ impl Render for ConfigurationView {
                                     this.child(
                                         Button::new("ollama-site", "Ollama")
                                             .style(ButtonStyle::Subtle)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::XSmall)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::XSmall)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE))
                                             .into_any_element(),
                                     )
@@ -959,9 +957,11 @@ impl Render for ConfigurationView {
                                     this.child(
                                         Button::new("download_ollama_button", "Download Ollama")
                                             .style(ButtonStyle::Subtle)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::XSmall)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::XSmall)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(move |_, _, cx| {
                                                 cx.open_url(OLLAMA_DOWNLOAD_URL)
                                             })
@@ -972,9 +972,11 @@ impl Render for ConfigurationView {
                             .child(
                                 Button::new("view-models", "View All Models")
                                     .style(ButtonStyle::Subtle)
-                                    .icon(IconName::ArrowUpRight)
-                                    .icon_size(IconSize::XSmall)
-                                    .icon_color(Color::Muted)
+                                    .end_icon(
+                                        Icon::new(IconName::ArrowUpRight)
+                                            .size(IconSize::XSmall)
+                                            .color(Color::Muted),
+                                    )
                                     .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)),
                             ),
                     )
@@ -1005,9 +1007,9 @@ impl Render for ConfigurationView {
                         } else {
                             this.child(
                                 Button::new("retry_ollama_models", "Connect")
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::XSmall)
-                                    .icon(IconName::PlayOutlined)
+                                    .start_icon(
+                                        Icon::new(IconName::PlayOutlined).size(IconSize::XSmall),
+                                    )
                                     .on_click(cx.listener(move |this, _, window, cx| {
                                         this.retry_connection(window, cx)
                                     })),

crates/language_models/src/provider/open_ai.rs 🔗

@@ -310,6 +310,8 @@ impl LanguageModel for OpenAiLanguageModel {
             | Model::FivePointTwo
             | Model::FivePointTwoCodex
             | Model::FivePointThreeCodex
+            | Model::FivePointFour
+            | Model::FivePointFourPro
             | Model::O1
             | Model::O3 => true,
             Model::ThreePointFiveTurbo
@@ -600,7 +602,10 @@ pub fn into_open_ai_response(
         } else {
             None
         },
-        reasoning: reasoning_effort.map(|effort| open_ai::responses::ReasoningConfig { effort }),
+        reasoning: reasoning_effort.map(|effort| open_ai::responses::ReasoningConfig {
+            effort,
+            summary: Some(open_ai::responses::ReasoningSummaryMode::Auto),
+        }),
     }
 }
 
@@ -961,10 +966,20 @@ impl OpenAiResponseEventMapper {
                             self.function_calls_by_item.insert(item_id, entry);
                         }
                     }
-                    ResponseOutputItem::Unknown => {}
+                    ResponseOutputItem::Reasoning(_) | ResponseOutputItem::Unknown => {}
                 }
                 events
             }
+            ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => {
+                if delta.is_empty() {
+                    Vec::new()
+                } else {
+                    vec![Ok(LanguageModelCompletionEvent::Thinking {
+                        text: delta,
+                        signature: None,
+                    })]
+                }
+            }
             ResponsesStreamEvent::OutputTextDelta { delta, .. } => {
                 if delta.is_empty() {
                     Vec::new()
@@ -1073,10 +1088,22 @@ impl OpenAiResponseEventMapper {
                     error.message
                 )))]
             }
-            ResponsesStreamEvent::OutputTextDone { .. } => Vec::new(),
-            ResponsesStreamEvent::OutputItemDone { .. }
+            ResponsesStreamEvent::ReasoningSummaryPartAdded { summary_index, .. } => {
+                if summary_index > 0 {
+                    vec![Ok(LanguageModelCompletionEvent::Thinking {
+                        text: "\n\n".to_string(),
+                        signature: None,
+                    })]
+                } else {
+                    Vec::new()
+                }
+            }
+            ResponsesStreamEvent::OutputTextDone { .. }
+            | ResponsesStreamEvent::OutputItemDone { .. }
             | ResponsesStreamEvent::ContentPartAdded { .. }
             | ResponsesStreamEvent::ContentPartDone { .. }
+            | ResponsesStreamEvent::ReasoningSummaryTextDone { .. }
+            | ResponsesStreamEvent::ReasoningSummaryPartDone { .. }
             | ResponsesStreamEvent::Created { .. }
             | ResponsesStreamEvent::InProgress { .. }
             | ResponsesStreamEvent::Unknown => Vec::new(),
@@ -1217,13 +1244,13 @@ pub fn count_open_ai_tokens(
             | Model::FiveCodex
             | Model::FiveMini
             | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
-            // GPT-5.1, 5.2, 5.2-codex, and 5.3-codex don't have dedicated tiktoken support; use gpt-5 tokenizer
+            // GPT-5.1, 5.2, 5.2-codex, 5.3-codex, 5.4, and 5.4-pro don't have dedicated tiktoken support; use gpt-5 tokenizer
             Model::FivePointOne
             | Model::FivePointTwo
             | Model::FivePointTwoCodex
-            | Model::FivePointThreeCodex => {
-                tiktoken_rs::num_tokens_from_messages("gpt-5", &messages)
-            }
+            | Model::FivePointThreeCodex
+            | Model::FivePointFour
+            | Model::FivePointFourPro => tiktoken_rs::num_tokens_from_messages("gpt-5", &messages),
         }
         .map(|tokens| tokens as u64)
     })
@@ -1388,9 +1415,11 @@ impl Render for ConfigurationView {
             )
             .child(
                 Button::new("docs", "Learn More")
-                    .icon(IconName::ArrowUpRight)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .end_icon(
+                        Icon::new(IconName::ArrowUpRight)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .on_click(move |_, _window, cx| {
                         cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible")
                     }),
@@ -1414,8 +1443,9 @@ mod tests {
     use gpui::TestAppContext;
     use language_model::{LanguageModelRequestMessage, LanguageModelRequestTool};
     use open_ai::responses::{
-        ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage, ResponseStatusDetails,
-        ResponseSummary, ResponseUsage, StreamEvent as ResponsesStreamEvent,
+        ReasoningSummaryPart, ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage,
+        ResponseReasoningItem, ResponseStatusDetails, ResponseSummary, ResponseUsage,
+        StreamEvent as ResponsesStreamEvent,
     };
     use pretty_assertions::assert_eq;
     use serde_json::json;
@@ -1673,7 +1703,7 @@ mod tests {
                 }
             ],
             "prompt_cache_key": "thread-123",
-            "reasoning": { "effort": "low" }
+            "reasoning": { "effort": "low", "summary": "auto" }
         });
 
         assert_eq!(serialized, expected);
@@ -2112,4 +2142,166 @@ mod tests {
             })
         ));
     }
+
+    #[test]
+    fn responses_stream_maps_reasoning_summary_deltas() {
+        let events = vec![
+            ResponsesStreamEvent::OutputItemAdded {
+                output_index: 0,
+                sequence_number: None,
+                item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
+                    id: Some("rs_123".into()),
+                    summary: vec![],
+                }),
+            },
+            ResponsesStreamEvent::ReasoningSummaryPartAdded {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                summary_index: 0,
+            },
+            ResponsesStreamEvent::ReasoningSummaryTextDelta {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                delta: "Thinking about".into(),
+            },
+            ResponsesStreamEvent::ReasoningSummaryTextDelta {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                delta: " the answer".into(),
+            },
+            ResponsesStreamEvent::ReasoningSummaryTextDone {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                text: "Thinking about the answer".into(),
+            },
+            ResponsesStreamEvent::ReasoningSummaryPartDone {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                summary_index: 0,
+            },
+            ResponsesStreamEvent::ReasoningSummaryPartAdded {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                summary_index: 1,
+            },
+            ResponsesStreamEvent::ReasoningSummaryTextDelta {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                delta: "Second part".into(),
+            },
+            ResponsesStreamEvent::ReasoningSummaryTextDone {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                text: "Second part".into(),
+            },
+            ResponsesStreamEvent::ReasoningSummaryPartDone {
+                item_id: "rs_123".into(),
+                output_index: 0,
+                summary_index: 1,
+            },
+            ResponsesStreamEvent::OutputItemDone {
+                output_index: 0,
+                sequence_number: None,
+                item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
+                    id: Some("rs_123".into()),
+                    summary: vec![
+                        ReasoningSummaryPart::SummaryText {
+                            text: "Thinking about the answer".into(),
+                        },
+                        ReasoningSummaryPart::SummaryText {
+                            text: "Second part".into(),
+                        },
+                    ],
+                }),
+            },
+            ResponsesStreamEvent::OutputItemAdded {
+                output_index: 1,
+                sequence_number: None,
+                item: response_item_message("msg_456"),
+            },
+            ResponsesStreamEvent::OutputTextDelta {
+                item_id: "msg_456".into(),
+                output_index: 1,
+                content_index: Some(0),
+                delta: "The answer is 42".into(),
+            },
+            ResponsesStreamEvent::Completed {
+                response: ResponseSummary::default(),
+            },
+        ];
+
+        let mapped = map_response_events(events);
+
+        let thinking_events: Vec<_> = mapped
+            .iter()
+            .filter(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. }))
+            .collect();
+        assert_eq!(
+            thinking_events.len(),
+            4,
+            "expected 4 thinking events (2 deltas + separator + second delta), got {:?}",
+            thinking_events,
+        );
+
+        assert!(matches!(
+            &thinking_events[0],
+            LanguageModelCompletionEvent::Thinking { text, .. } if text == "Thinking about"
+        ));
+        assert!(matches!(
+            &thinking_events[1],
+            LanguageModelCompletionEvent::Thinking { text, .. } if text == " the answer"
+        ));
+        assert!(
+            matches!(
+                &thinking_events[2],
+                LanguageModelCompletionEvent::Thinking { text, .. } if text == "\n\n"
+            ),
+            "expected separator between summary parts"
+        );
+        assert!(matches!(
+            &thinking_events[3],
+            LanguageModelCompletionEvent::Thinking { text, .. } if text == "Second part"
+        ));
+
+        assert!(mapped.iter().any(|e| matches!(
+            e,
+            LanguageModelCompletionEvent::Text(t) if t == "The answer is 42"
+        )));
+    }
+
+    #[test]
+    fn responses_stream_maps_reasoning_from_done_only() {
+        let events = vec![
+            ResponsesStreamEvent::OutputItemAdded {
+                output_index: 0,
+                sequence_number: None,
+                item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
+                    id: Some("rs_789".into()),
+                    summary: vec![],
+                }),
+            },
+            ResponsesStreamEvent::OutputItemDone {
+                output_index: 0,
+                sequence_number: None,
+                item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
+                    id: Some("rs_789".into()),
+                    summary: vec![ReasoningSummaryPart::SummaryText {
+                        text: "Summary without deltas".into(),
+                    }],
+                }),
+            },
+            ResponsesStreamEvent::Completed {
+                response: ResponseSummary::default(),
+            },
+        ];
+
+        let mapped = map_response_events(events);
+
+        assert!(
+            !mapped
+                .iter()
+                .any(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. })),
+            "OutputItemDone reasoning should not produce Thinking events (no delta/done text events)"
+        );
+    }
 }

crates/language_models/src/provider/open_ai_compatible.rs 🔗

@@ -545,9 +545,7 @@ impl Render for ConfigurationView {
                         .child(
                             Button::new("reset-api-key", "Reset API Key")
                                 .label_size(LabelSize::Small)
-                                .icon(IconName::Undo)
-                                .icon_size(IconSize::Small)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
                                 .layer(ElevationIndex::ModalSurface)
                                 .when(env_var_set, |this| {
                                     this.tooltip(Tooltip::text(format!("To reset your API key, unset the {env_var_name} environment variable.")))

crates/language_onboarding/src/python.rs 🔗

@@ -56,10 +56,8 @@ impl Render for BasedPyrightBanner {
                                 .gap_0p5()
                                 .child(
                                     Button::new("learn-more", "Learn More")
-                                        .icon(IconName::ArrowUpRight)
                                         .label_size(LabelSize::Small)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon_color(Color::Muted)
+                                        .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall).color(Color::Muted))
                                         .on_click(|_, _, cx| {
                                             cx.open_url("https://zed.dev/docs/languages/python")
                                         }),

crates/language_tools/src/lsp_log_view.rs 🔗

@@ -18,7 +18,7 @@ use project::{
 };
 use proto::toggle_lsp_logs::LogType;
 use std::{any::TypeId, borrow::Cow, sync::Arc};
-use ui::{Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState, prelude::*};
+use ui::{Checkbox, ContextMenu, PopoverMenu, ToggleState, prelude::*};
 use util::ResultExt as _;
 use workspace::{
     SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
@@ -969,9 +969,11 @@ impl Render for LspLogToolbarItemView {
                         })
                         .unwrap_or_else(|| "No server selected".into()),
                 )
-                .icon(IconName::ChevronDown)
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted),
+                .end_icon(
+                    Icon::new(IconName::ChevronDown)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                ),
             )
             .menu({
                 let log_view = log_view.clone();
@@ -1030,10 +1032,11 @@ impl Render for LspLogToolbarItemView {
             PopoverMenu::new("LspViewSelector")
                 .anchor(Corner::TopLeft)
                 .trigger(
-                    Button::new("language_server_menu_header", label)
-                        .icon(IconName::ChevronDown)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted),
+                    Button::new("language_server_menu_header", label).end_icon(
+                        Icon::new(IconName::ChevronDown)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    ),
                 )
                 .menu(move |window, cx| {
                     let log_toolbar_view = log_toolbar_view.upgrade()?;
@@ -1125,9 +1128,11 @@ impl Render for LspLogToolbarItemView {
                                                 "language_server_trace_level_selector",
                                                 "Trace level",
                                             )
-                                            .icon(IconName::ChevronDown)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Muted),
+                                            .end_icon(
+                                                Icon::new(IconName::ChevronDown)
+                                                    .size(IconSize::Small)
+                                                    .color(Color::Muted),
+                                            ),
                                         )
                                         .menu({
                                             let log_view = log_view;
@@ -1193,9 +1198,11 @@ impl Render for LspLogToolbarItemView {
                                                 "language_server_log_level_selector",
                                                 "Log level",
                                             )
-                                            .icon(IconName::ChevronDown)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Muted),
+                                            .end_icon(
+                                                Icon::new(IconName::ChevronDown)
+                                                    .size(IconSize::Small)
+                                                    .color(Color::Muted),
+                                            ),
                                         )
                                         .menu({
                                             let log_view = log_view;

crates/languages/Cargo.toml 🔗

@@ -98,7 +98,6 @@ util.workspace = true
 
 [dev-dependencies]
 pretty_assertions.workspace = true
-text.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 tree-sitter-bash.workspace = true
 tree-sitter-c.workspace = true
@@ -109,4 +108,3 @@ tree-sitter-python.workspace = true
 tree-sitter-typescript.workspace = true
 tree-sitter.workspace = true
 unindent.workspace = true
-workspace = { workspace = true, features = ["test-support"] }

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

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

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

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

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

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

crates/languages/src/python.rs 🔗

@@ -159,6 +159,75 @@ fn process_pyright_completions(items: &mut [lsp::CompletionItem]) {
     }
 }
 
+fn label_for_pyright_completion(
+    item: &lsp::CompletionItem,
+    language: &Arc<language::Language>,
+) -> Option<language::CodeLabel> {
+    let label = &item.label;
+    let label_len = label.len();
+    let grammar = language.grammar()?;
+    let highlight_id = match item.kind? {
+        lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
+        lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
+        lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
+        lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
+        lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
+        _ => {
+            return None;
+        }
+    };
+    let mut text = label.clone();
+    if let Some(completion_details) = item
+        .label_details
+        .as_ref()
+        .and_then(|details| details.description.as_ref())
+    {
+        write!(&mut text, " {}", completion_details).ok();
+    }
+    Some(language::CodeLabel::filtered(
+        text,
+        label_len,
+        item.filter_text.as_deref(),
+        highlight_id
+            .map(|id| (0..label_len, id))
+            .into_iter()
+            .collect(),
+    ))
+}
+
+fn label_for_python_symbol(
+    symbol: &Symbol,
+    language: &Arc<language::Language>,
+) -> Option<language::CodeLabel> {
+    let name = &symbol.name;
+    let (text, filter_range, display_range) = match symbol.kind {
+        lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
+            let text = format!("def {}():\n", name);
+            let filter_range = 4..4 + name.len();
+            let display_range = 0..filter_range.end;
+            (text, filter_range, display_range)
+        }
+        lsp::SymbolKind::CLASS => {
+            let text = format!("class {}:", name);
+            let filter_range = 6..6 + name.len();
+            let display_range = 0..filter_range.end;
+            (text, filter_range, display_range)
+        }
+        lsp::SymbolKind::CONSTANT => {
+            let text = format!("{} = 0", name);
+            let filter_range = 0..name.len();
+            let display_range = 0..filter_range.end;
+            (text, filter_range, display_range)
+        }
+        _ => return None,
+    };
+    Some(language::CodeLabel::new(
+        text[display_range.clone()].to_string(),
+        filter_range,
+        language.highlight_text(&text.as_str().into(), display_range),
+    ))
+}
+
 pub struct TyLspAdapter {
     fs: Arc<dyn Fs>,
 }
@@ -255,6 +324,14 @@ impl LspAdapter for TyLspAdapter {
         ))
     }
 
+    async fn label_for_symbol(
+        &self,
+        symbol: &language::Symbol,
+        language: &Arc<language::Language>,
+    ) -> Option<language::CodeLabel> {
+        label_for_python_symbol(symbol, language)
+    }
+
     async fn workspace_configuration(
         self: Arc<Self>,
         delegate: &Arc<dyn LspAdapterDelegate>,
@@ -531,36 +608,7 @@ impl LspAdapter for PyrightLspAdapter {
         item: &lsp::CompletionItem,
         language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
-        let label = &item.label;
-        let label_len = label.len();
-        let grammar = language.grammar()?;
-        let highlight_id = match item.kind? {
-            lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
-            lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
-            lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
-            lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
-            lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
-            _ => {
-                return None;
-            }
-        };
-        let mut text = label.clone();
-        if let Some(completion_details) = item
-            .label_details
-            .as_ref()
-            .and_then(|details| details.description.as_ref())
-        {
-            write!(&mut text, " {}", completion_details).ok();
-        }
-        Some(language::CodeLabel::filtered(
-            text,
-            label_len,
-            item.filter_text.as_deref(),
-            highlight_id
-                .map(|id| (0..label_len, id))
-                .into_iter()
-                .collect(),
-        ))
+        label_for_pyright_completion(item, language)
     }
 
     async fn label_for_symbol(
@@ -568,34 +616,7 @@ impl LspAdapter for PyrightLspAdapter {
         symbol: &language::Symbol,
         language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
-        let name = &symbol.name;
-        let (text, filter_range, display_range) = match symbol.kind {
-            lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
-                let text = format!("def {}():\n", name);
-                let filter_range = 4..4 + name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            lsp::SymbolKind::CLASS => {
-                let text = format!("class {}:", name);
-                let filter_range = 6..6 + name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            lsp::SymbolKind::CONSTANT => {
-                let text = format!("{} = 0", name);
-                let filter_range = 0..name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            _ => return None,
-        };
-
-        Some(language::CodeLabel::new(
-            text[display_range.clone()].to_string(),
-            filter_range,
-            language.highlight_text(&text.as_str().into(), display_range),
-        ))
+        label_for_python_symbol(symbol, language)
     }
 
     async fn workspace_configuration(
@@ -1378,12 +1399,9 @@ impl ToolchainLister for PythonToolchainProvider {
 
             match toolchain.environment.kind {
                 Some(PythonEnvironmentKind::Conda) => {
-                    let Some(manager_info) = &toolchain.environment.manager else {
+                    if toolchain.environment.manager.is_none() {
                         return vec![];
                     };
-                    if smol::fs::metadata(&manager_info.executable).await.is_err() {
-                        return vec![];
-                    }
 
                     let manager = match conda_manager {
                         settings::CondaManager::Conda => "conda",
@@ -1741,33 +1759,7 @@ impl LspAdapter for PyLspAdapter {
         symbol: &language::Symbol,
         language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
-        let name = &symbol.name;
-        let (text, filter_range, display_range) = match symbol.kind {
-            lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
-                let text = format!("def {}():\n", name);
-                let filter_range = 4..4 + name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            lsp::SymbolKind::CLASS => {
-                let text = format!("class {}:", name);
-                let filter_range = 6..6 + name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            lsp::SymbolKind::CONSTANT => {
-                let text = format!("{} = 0", name);
-                let filter_range = 0..name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            _ => return None,
-        };
-        Some(language::CodeLabel::new(
-            text[display_range.clone()].to_string(),
-            filter_range,
-            language.highlight_text(&text.as_str().into(), display_range),
-        ))
+        label_for_python_symbol(symbol, language)
     }
 
     async fn workspace_configuration(
@@ -1849,6 +1841,17 @@ impl LspInstaller for PyLspAdapter {
     ) -> Option<LanguageServerBinary> {
         if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await {
             let env = delegate.shell_env().await;
+            delegate
+                .try_exec(LanguageServerBinary {
+                    path: pylsp_bin.clone(),
+                    arguments: vec!["--version".into()],
+                    env: Some(env.clone()),
+                })
+                .await
+                .inspect_err(|err| {
+                    log::warn!("failed to validate user-installed pylsp at {pylsp_bin:?}: {err:#}")
+                })
+                .ok()?;
             Some(LanguageServerBinary {
                 path: pylsp_bin,
                 env: Some(env),
@@ -1857,7 +1860,21 @@ impl LspInstaller for PyLspAdapter {
         } else {
             let toolchain = toolchain?;
             let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp");
-            pylsp_path.exists().then(|| LanguageServerBinary {
+            if !pylsp_path.exists() {
+                return None;
+            }
+            delegate
+                .try_exec(LanguageServerBinary {
+                    path: toolchain.path.to_string().into(),
+                    arguments: vec![pylsp_path.clone().into(), "--version".into()],
+                    env: None,
+                })
+                .await
+                .inspect_err(|err| {
+                    log::warn!("failed to validate toolchain pylsp at {pylsp_path:?}: {err:#}")
+                })
+                .ok()?;
+            Some(LanguageServerBinary {
                 path: toolchain.path.to_string().into(),
                 arguments: vec![pylsp_path.into()],
                 env: None,
@@ -1997,36 +2014,7 @@ impl LspAdapter for BasedPyrightLspAdapter {
         item: &lsp::CompletionItem,
         language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
-        let label = &item.label;
-        let label_len = label.len();
-        let grammar = language.grammar()?;
-        let highlight_id = match item.kind? {
-            lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
-            lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"),
-            lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"),
-            lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"),
-            lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"),
-            _ => {
-                return None;
-            }
-        };
-        let mut text = label.clone();
-        if let Some(completion_details) = item
-            .label_details
-            .as_ref()
-            .and_then(|details| details.description.as_ref())
-        {
-            write!(&mut text, " {}", completion_details).ok();
-        }
-        Some(language::CodeLabel::filtered(
-            text,
-            label_len,
-            item.filter_text.as_deref(),
-            highlight_id
-                .map(|id| (0..label.len(), id))
-                .into_iter()
-                .collect(),
-        ))
+        label_for_pyright_completion(item, language)
     }
 
     async fn label_for_symbol(
@@ -2034,33 +2022,7 @@ impl LspAdapter for BasedPyrightLspAdapter {
         symbol: &Symbol,
         language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
-        let name = &symbol.name;
-        let (text, filter_range, display_range) = match symbol.kind {
-            lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
-                let text = format!("def {}():\n", name);
-                let filter_range = 4..4 + name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            lsp::SymbolKind::CLASS => {
-                let text = format!("class {}:", name);
-                let filter_range = 6..6 + name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            lsp::SymbolKind::CONSTANT => {
-                let text = format!("{} = 0", name);
-                let filter_range = 0..name.len();
-                let display_range = 0..filter_range.end;
-                (text, filter_range, display_range)
-            }
-            _ => return None,
-        };
-        Some(language::CodeLabel::new(
-            text[display_range.clone()].to_string(),
-            filter_range,
-            language.highlight_text(&text.as_str().into(), display_range),
-        ))
+        label_for_python_symbol(symbol, language)
     }
 
     async fn workspace_configuration(

crates/languages/src/rust/injections.scm 🔗

@@ -10,7 +10,7 @@
     (scoped_identifier
       (identifier) @_macro_name .)
   ]
-  (#not-any-of? @_macro_name "view" "html")
+  (#not-any-of? @_macro_name "view" "html" "bsn")
   (token_tree) @injection.content
   (#set! injection.language "rust"))
 

crates/languages/src/tsx/brackets.scm 🔗

@@ -7,14 +7,17 @@
 ("{" @open
   "}" @close)
 
-("<" @open
+(("<" @open
   ">" @close)
+  (#set! rainbow.exclude))
 
-("<" @open
+(("<" @open
   "/>" @close)
+  (#set! rainbow.exclude))
 
-("</" @open
+(("</" @open
   ">" @close)
+  (#set! rainbow.exclude))
 
 (("\"" @open
   "\"" @close)

crates/livekit_client/Cargo.toml 🔗

@@ -61,7 +61,6 @@ objc.workspace = true
 collections = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 gpui_platform.workspace = true
-sha2.workspace = true
 simplelog.workspace = true
 
 [build-dependencies]

crates/livekit_client/src/lib.rs 🔗

@@ -1,8 +1,8 @@
 use anyhow::Context as _;
 use collections::HashMap;
+use cpal::DeviceId;
 
 mod remote_video_track_view;
-use cpal::traits::HostTrait as _;
 pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent};
 use rodio::DeviceTrait as _;
 
@@ -192,24 +192,18 @@ pub enum RoomEvent {
 
 pub(crate) fn default_device(
     input: bool,
+    device_id: Option<&DeviceId>,
 ) -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> {
-    let device;
-    let config;
-    if input {
-        device = cpal::default_host()
-            .default_input_device()
-            .context("no audio input device available")?;
-        config = device
+    let device = audio::resolve_device(device_id, input)?;
+    let config = if input {
+        device
             .default_input_config()
-            .context("failed to get default input config")?;
+            .context("failed to get default input config")?
     } else {
-        device = cpal::default_host()
-            .default_output_device()
-            .context("no audio output device available")?;
-        config = device
+        device
             .default_output_config()
-            .context("failed to get default output config")?;
-    }
+            .context("failed to get default output config")?
+    };
     Ok((device, config))
 }
 

crates/livekit_client/src/livekit_client.rs 🔗

@@ -150,7 +150,10 @@ impl Room {
             info!("Using experimental.rodio_audio audio pipeline for output");
             playback::play_remote_audio_track(&track.0, speaker, cx)
         } else if speaker.sends_legacy_audio {
-            Ok(self.playback.play_remote_audio_track(&track.0))
+            let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
+            Ok(self
+                .playback
+                .play_remote_audio_track(&track.0, output_audio_device))
         } else {
             Err(anyhow!("Client version too old to play audio in call"))
         }

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

@@ -1,6 +1,7 @@
 use anyhow::{Context as _, Result};
 
 use audio::{AudioSettings, CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE};
+use cpal::DeviceId;
 use cpal::traits::{DeviceTrait, StreamTrait as _};
 use futures::channel::mpsc::UnboundedSender;
 use futures::{Stream, StreamExt as _};
@@ -28,7 +29,7 @@ use std::cell::RefCell;
 use std::sync::Weak;
 use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
 use std::time::Duration;
-use std::{borrow::Cow, collections::VecDeque, sync::Arc, thread};
+use std::{borrow::Cow, collections::VecDeque, sync::Arc};
 use util::{ResultExt as _, maybe};
 
 mod source;
@@ -91,8 +92,9 @@ impl AudioStack {
     pub(crate) fn play_remote_audio_track(
         &self,
         track: &livekit::track::RemoteAudioTrack,
+        output_audio_device: Option<DeviceId>,
     ) -> AudioStream {
-        let output_task = self.start_output();
+        let output_task = self.start_output(output_audio_device);
 
         let next_ssrc = self.next_ssrc.fetch_add(1, Ordering::Relaxed);
         let source = AudioMixerSource {
@@ -109,7 +111,7 @@ impl AudioStack {
             source.num_channels as i32,
         );
 
-        let receive_task = self.executor.spawn({
+        let receive_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, {
             let source = source.clone();
             async move {
                 while let Some(frame) = stream.next().await {
@@ -130,19 +132,22 @@ impl AudioStack {
         }
     }
 
-    fn start_output(&self) -> Arc<Task<()>> {
+    fn start_output(&self, output_audio_device: Option<DeviceId>) -> Arc<Task<()>> {
         if let Some(task) = self._output_task.borrow().upgrade() {
             return task;
         }
         let task = Arc::new(self.executor.spawn({
             let apm = self.apm.clone();
             let mixer = self.mixer.clone();
+            let executor = self.executor.clone();
             async move {
                 Self::play_output(
+                    executor,
                     apm,
                     mixer,
                     LEGACY_SAMPLE_RATE.get(),
                     LEGACY_CHANNEL_COUNT.get().into(),
+                    output_audio_device,
                 )
                 .await
                 .log_err();
@@ -197,7 +202,7 @@ impl AudioStack {
         let apm = self.apm.clone();
 
         let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded();
-        let transmit_task = self.executor.spawn({
+        let transmit_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, {
             async move {
                 while let Some(frame) = frame_rx.next().await {
                     source.capture_frame(&frame).await.log_err();
@@ -219,12 +224,18 @@ impl AudioStack {
                     Ok(())
                 })
         } else {
+            let input_audio_device =
+                AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone())
+                    .flatten();
+            let executor = self.executor.clone();
             self.executor.spawn(async move {
                 Self::capture_input(
+                    executor,
                     apm,
                     frame_tx,
                     LEGACY_SAMPLE_RATE.get(),
                     LEGACY_CHANNEL_COUNT.get().into(),
+                    input_audio_device,
                 )
                 .await
             })
@@ -243,10 +254,12 @@ impl AudioStack {
     }
 
     async fn play_output(
+        executor: BackgroundExecutor,
         apm: Arc<Mutex<apm::AudioProcessingModule>>,
         mixer: Arc<Mutex<audio_mixer::AudioMixer>>,
         sample_rate: u32,
-        num_channels: u32,
+        _num_channels: u32,
+        output_audio_device: Option<DeviceId>,
     ) -> Result<()> {
         // Prevent App Nap from throttling audio playback on macOS.
         // This guard is held for the entire duration of audio output.
@@ -255,16 +268,17 @@ impl AudioStack {
 
         loop {
             let mut device_change_listener = DeviceChangeListener::new(false)?;
-            let (output_device, output_config) = crate::default_device(false)?;
+            let (output_device, output_config) =
+                crate::default_device(false, output_audio_device.as_ref())?;
+            info!("Output config: {output_config:?}");
             let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>();
             let mixer = mixer.clone();
             let apm = apm.clone();
             let mut resampler = audio_resampler::AudioResampler::default();
             let mut buf = Vec::new();
 
-            thread::Builder::new()
-                .name("AudioPlayback".to_owned())
-                .spawn(move || {
+            executor
+                .spawn_with_priority(Priority::RealtimeAudio, async move {
                     let output_stream = output_device.build_output_stream(
                         &output_config.config(),
                         {
@@ -287,7 +301,12 @@ impl AudioStack {
                                     let sampled = resampler.remix_and_resample(
                                         mixed,
                                         sample_rate / 100,
-                                        num_channels,
+                                        // We need to assume output number of channels as otherwise we will
+                                        // crash in process_reverse_stream otherwise as livekit's audio resampler
+                                        // does not seem to support non-matching channel counts.
+                                        // NOTE: you can verify this by debug printing buf.len() after this stage.
+                                        // For 2->4 channel upmix, we should see buf.len=1920, buf we get only 960.
+                                        output_config.channels() as u32,
                                         sample_rate,
                                         output_config.channels() as u32,
                                         output_config.sample_rate(),
@@ -315,7 +334,7 @@ impl AudioStack {
                     // Block forever to keep the output stream alive
                     end_on_drop_rx.recv().ok();
                 })
-                .unwrap();
+                .detach();
 
             device_change_listener.next().await;
             drop(end_on_drop_tx)
@@ -323,22 +342,23 @@ impl AudioStack {
     }
 
     async fn capture_input(
+        executor: BackgroundExecutor,
         apm: Arc<Mutex<apm::AudioProcessingModule>>,
         frame_tx: UnboundedSender<AudioFrame<'static>>,
         sample_rate: u32,
         num_channels: u32,
+        input_audio_device: Option<DeviceId>,
     ) -> Result<()> {
         loop {
             let mut device_change_listener = DeviceChangeListener::new(true)?;
-            let (device, config) = crate::default_device(true)?;
+            let (device, config) = crate::default_device(true, input_audio_device.as_ref())?;
             let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>();
             let apm = apm.clone();
             let frame_tx = frame_tx.clone();
             let mut resampler = audio_resampler::AudioResampler::default();
 
-            thread::Builder::new()
-                .name("AudioCapture".to_owned())
-                .spawn(move || {
+            executor
+                .spawn_with_priority(Priority::RealtimeAudio, async move {
                     maybe!({
                         if let Some(desc) = device.description().ok() {
                             log::info!("Using microphone: {}", desc.name())
@@ -410,7 +430,7 @@ impl AudioStack {
                     })
                     .log_err();
                 })
-                .unwrap();
+                .detach();
 
             device_change_listener.next().await;
             drop(end_on_drop_tx)

crates/livekit_client/src/record.rs 🔗

@@ -7,20 +7,22 @@ use std::{
 };
 
 use anyhow::{Context, Result};
+use cpal::DeviceId;
 use cpal::traits::{DeviceTrait, StreamTrait};
 use rodio::{buffer::SamplesBuffer, conversions::SampleTypeConverter};
 use util::ResultExt;
 
 pub struct CaptureInput {
     pub name: String,
+    pub input_device: Option<DeviceId>,
     config: cpal::SupportedStreamConfig,
     samples: Arc<Mutex<Vec<i16>>>,
     _stream: cpal::Stream,
 }
 
 impl CaptureInput {
-    pub fn start() -> anyhow::Result<Self> {
-        let (device, config) = crate::default_device(true)?;
+    pub fn start(input_device: Option<DeviceId>) -> anyhow::Result<Self> {
+        let (device, config) = crate::default_device(true, input_device.as_ref())?;
         let name = device
             .description()
             .map(|desc| desc.name().to_string())
@@ -32,6 +34,7 @@ impl CaptureInput {
 
         Ok(Self {
             name,
+            input_device,
             _stream: stream,
             config,
             samples,

crates/lmstudio/src/lmstudio.rs 🔗

@@ -354,14 +354,19 @@ pub struct ResponseMessageDelta {
 pub async fn complete(
     client: &dyn HttpClient,
     api_url: &str,
+    api_key: Option<&str>,
     request: ChatCompletionRequest,
 ) -> Result<ChatResponse> {
     let uri = format!("{api_url}/chat/completions");
-    let request_builder = HttpRequest::builder()
+    let mut request_builder = HttpRequest::builder()
         .method(Method::POST)
         .uri(uri)
         .header("Content-Type", "application/json");
 
+    if let Some(api_key) = api_key {
+        request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
+    }
+
     let serialized_request = serde_json::to_string(&request)?;
     let request = request_builder.body(AsyncBody::from(serialized_request))?;
 
@@ -386,14 +391,19 @@ pub async fn complete(
 pub async fn stream_chat_completion(
     client: &dyn HttpClient,
     api_url: &str,
+    api_key: Option<&str>,
     request: ChatCompletionRequest,
 ) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
     let uri = format!("{api_url}/chat/completions");
-    let request_builder = http::Request::builder()
+    let mut request_builder = http::Request::builder()
         .method(Method::POST)
         .uri(uri)
         .header("Content-Type", "application/json");
 
+    if let Some(api_key) = api_key {
+        request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
+    }
+
     let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
     let mut response = client.send(request).await?;
     if response.status().is_success() {
@@ -434,14 +444,19 @@ pub async fn stream_chat_completion(
 pub async fn get_models(
     client: &dyn HttpClient,
     api_url: &str,
+    api_key: Option<&str>,
     _: Option<Duration>,
 ) -> Result<Vec<ModelEntry>> {
     let uri = format!("{api_url}/models");
-    let request_builder = HttpRequest::builder()
+    let mut request_builder = HttpRequest::builder()
         .method(Method::GET)
         .uri(uri)
         .header("Accept", "application/json");
 
+    if let Some(api_key) = api_key {
+        request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key));
+    }
+
     let request = request_builder.body(AsyncBody::default())?;
 
     let mut response = client.send(request).await?;

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -312,6 +312,10 @@ impl MarkdownPreviewView {
         cx: &mut Context<Self>,
     ) {
         if let Some(state) = &self.active_editor {
+            // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing
+            if wait_for_debounce && self.parsing_markdown_task.is_some() {
+                return;
+            }
             self.parsing_markdown_task = Some(self.parse_markdown_in_background(
                 wait_for_debounce,
                 state.editor.clone(),
@@ -355,6 +359,7 @@ impl MarkdownPreviewView {
                 let scroll_top = view.list_state.logical_scroll_top();
                 view.list_state.reset(markdown_blocks_count);
                 view.list_state.scroll_to(scroll_top);
+                view.parsing_markdown_task = None;
                 cx.notify();
             })
         })

crates/multi_buffer/Cargo.toml 🔗

@@ -52,7 +52,6 @@ gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
-project = { workspace = true, features = ["test-support"] }
 rand.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 text = { workspace = true, features = ["test-support"] }

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -119,6 +119,7 @@ pub enum Event {
     DiffHunksToggled,
     Edited {
         edited_buffer: Option<Entity<Buffer>>,
+        is_local: bool,
     },
     TransactionUndone {
         transaction_id: TransactionId,
@@ -1912,6 +1913,7 @@ impl MultiBuffer {
 
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
         cx.emit(Event::ExcerptsAdded {
             buffer,
@@ -1974,6 +1976,7 @@ impl MultiBuffer {
         }
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
         cx.emit(Event::ExcerptsRemoved {
             ids,
@@ -1987,7 +1990,7 @@ impl MultiBuffer {
         &self,
         buffer_id: BufferId,
         cx: &App,
-    ) -> Vec<(ExcerptId, ExcerptRange<text::Anchor>)> {
+    ) -> Vec<(ExcerptId, Arc<BufferSnapshot>, ExcerptRange<text::Anchor>)> {
         let mut excerpts = Vec::new();
         let snapshot = self.read(cx);
         let mut cursor = snapshot.excerpts.cursor::<Option<&Locator>>(());
@@ -1997,7 +2000,7 @@ impl MultiBuffer {
                 if let Some(excerpt) = cursor.item()
                     && excerpt.locator == *locator
                 {
-                    excerpts.push((excerpt.id, excerpt.range.clone()));
+                    excerpts.push((excerpt.id, excerpt.buffer.clone(), excerpt.range.clone()));
                 }
             }
         }
@@ -2128,7 +2131,7 @@ impl MultiBuffer {
     ) -> Option<Anchor> {
         let mut found = None;
         let snapshot = buffer.read(cx).snapshot();
-        for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) {
+        for (excerpt_id, _, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) {
             let start = range.context.start.to_point(&snapshot);
             let end = range.context.end.to_point(&snapshot);
             if start <= point && point < end {
@@ -2157,7 +2160,7 @@ impl MultiBuffer {
         cx: &App,
     ) -> Option<Anchor> {
         let snapshot = buffer.read(cx).snapshot();
-        for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) {
+        for (excerpt_id, _, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) {
             if range.context.start.cmp(&anchor, &snapshot).is_le()
                 && range.context.end.cmp(&anchor, &snapshot).is_ge()
             {
@@ -2330,6 +2333,7 @@ impl MultiBuffer {
         }
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
         cx.emit(Event::ExcerptsRemoved {
             ids,
@@ -2394,8 +2398,9 @@ impl MultiBuffer {
         use language::BufferEvent;
         let buffer_id = buffer.read(cx).remote_id();
         cx.emit(match event {
-            BufferEvent::Edited => Event::Edited {
+            &BufferEvent::Edited { is_local } => Event::Edited {
                 edited_buffer: Some(buffer),
+                is_local,
             },
             BufferEvent::DirtyChanged => Event::DirtyChanged,
             BufferEvent::Saved => Event::Saved,
@@ -2484,6 +2489,7 @@ impl MultiBuffer {
         }
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
     }
 
@@ -2530,6 +2536,7 @@ impl MultiBuffer {
         }
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
     }
 
@@ -2769,6 +2776,7 @@ impl MultiBuffer {
         cx.emit(Event::DiffHunksToggled);
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
     }
 
@@ -2885,6 +2893,7 @@ impl MultiBuffer {
         cx.emit(Event::DiffHunksToggled);
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
     }
 
@@ -2952,6 +2961,7 @@ impl MultiBuffer {
         }
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
         cx.emit(Event::ExcerptsExpanded { ids: vec![id] });
         cx.notify();
@@ -3059,6 +3069,7 @@ impl MultiBuffer {
         }
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
         cx.emit(Event::ExcerptsExpanded { ids });
         cx.notify();
@@ -3702,6 +3713,7 @@ impl MultiBuffer {
         cx.emit(Event::DiffHunksToggled);
         cx.emit(Event::Edited {
             edited_buffer: None,
+            is_local: true,
         });
     }
 }

crates/multi_buffer/src/multi_buffer_tests.rs 🔗

@@ -171,12 +171,15 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) {
         &[
             Event::Edited {
                 edited_buffer: None,
+                is_local: true,
             },
             Event::Edited {
                 edited_buffer: None,
+                is_local: true,
             },
             Event::Edited {
                 edited_buffer: None,
+                is_local: true,
             }
         ]
     );
@@ -1285,7 +1288,7 @@ fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut App) {
         let mut ids = multibuffer
             .excerpts_for_buffer(buffer_2.read(cx).remote_id(), cx)
             .into_iter()
-            .map(|(id, _)| id);
+            .map(|(id, _, _)| id);
         (ids.next().unwrap(), ids.next().unwrap())
     });
     let snapshot_2 = multibuffer.read(cx).snapshot(cx);

crates/notifications/Cargo.toml 🔗

@@ -15,7 +15,7 @@ doctest = false
 [features]
 test-support = [
     "channel/test-support",
-    "collections/test-support",
+
     "gpui/test-support",
     "rpc/test-support",
 ]
@@ -37,8 +37,6 @@ zed_actions.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
-collections = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 rpc = { workspace = true, features = ["test-support"] }
-settings = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }

crates/onboarding/src/basics_page.rs 🔗

@@ -10,9 +10,8 @@ use theme::{
     ThemeSettings,
 };
 use ui::{
-    Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
-    ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
-    prelude::*, rems_from_px,
+    Divider, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup,
+    ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*,
 };
 use vim_mode_setting::VimModeSetting;
 
@@ -477,8 +476,7 @@ fn render_setting_import_button(
         .toggle_state(imported)
         .tab_index(tab_index)
         .when(imported, |this| {
-            this.icon(IconName::Check)
-                .icon_size(IconSize::Small)
+            this.end_icon(Icon::new(IconName::Check).size(IconSize::Small))
                 .color(Color::Success)
         })
         .on_click(move |_, window, cx| {

crates/onboarding/src/multibuffer_hint.rs 🔗

@@ -158,10 +158,11 @@ impl Render for MultibufferHint {
                     )
                     .child(
                         Button::new("open_docs", "Learn More")
-                            .icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .icon_position(IconPosition::End)
+                            .end_icon(
+                                Icon::new(IconName::ArrowUpRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .on_click(move |_event, _, cx| {
                                 cx.open_url("https://zed.dev/docs/multibuffers")
                             }),

crates/open_ai/src/open_ai.rs 🔗

@@ -90,6 +90,10 @@ pub enum Model {
     FivePointTwoCodex,
     #[serde(rename = "gpt-5.3-codex")]
     FivePointThreeCodex,
+    #[serde(rename = "gpt-5.4")]
+    FivePointFour,
+    #[serde(rename = "gpt-5.4-pro")]
+    FivePointFourPro,
     #[serde(rename = "custom")]
     Custom {
         name: String,
@@ -131,6 +135,8 @@ impl Model {
             "gpt-5.2" => Ok(Self::FivePointTwo),
             "gpt-5.2-codex" => Ok(Self::FivePointTwoCodex),
             "gpt-5.3-codex" => Ok(Self::FivePointThreeCodex),
+            "gpt-5.4" => Ok(Self::FivePointFour),
+            "gpt-5.4-pro" => Ok(Self::FivePointFourPro),
             invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
         }
     }
@@ -153,6 +159,8 @@ impl Model {
             Self::FivePointTwo => "gpt-5.2",
             Self::FivePointTwoCodex => "gpt-5.2-codex",
             Self::FivePointThreeCodex => "gpt-5.3-codex",
+            Self::FivePointFour => "gpt-5.4",
+            Self::FivePointFourPro => "gpt-5.4-pro",
             Self::Custom { name, .. } => name,
         }
     }
@@ -175,6 +183,8 @@ impl Model {
             Self::FivePointTwo => "gpt-5.2",
             Self::FivePointTwoCodex => "gpt-5.2-codex",
             Self::FivePointThreeCodex => "gpt-5.3-codex",
+            Self::FivePointFour => "gpt-5.4",
+            Self::FivePointFourPro => "gpt-5.4-pro",
             Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()),
         }
     }
@@ -191,12 +201,14 @@ impl Model {
             Self::O3 => 200_000,
             Self::Five => 272_000,
             Self::FiveCodex => 272_000,
-            Self::FiveMini => 272_000,
-            Self::FiveNano => 272_000,
+            Self::FiveMini => 400_000,
+            Self::FiveNano => 400_000,
             Self::FivePointOne => 400_000,
             Self::FivePointTwo => 400_000,
             Self::FivePointTwoCodex => 400_000,
             Self::FivePointThreeCodex => 400_000,
+            Self::FivePointFour => 1_050_000,
+            Self::FivePointFourPro => 1_050_000,
             Self::Custom { max_tokens, .. } => *max_tokens,
         }
     }
@@ -222,6 +234,8 @@ impl Model {
             Self::FivePointTwo => Some(128_000),
             Self::FivePointTwoCodex => Some(128_000),
             Self::FivePointThreeCodex => Some(128_000),
+            Self::FivePointFour => Some(128_000),
+            Self::FivePointFourPro => Some(128_000),
         }
     }
 
@@ -230,7 +244,7 @@ impl Model {
             Self::Custom {
                 reasoning_effort, ..
             } => reasoning_effort.to_owned(),
-            Self::FivePointThreeCodex => Some(ReasoningEffort::Medium),
+            Self::FivePointThreeCodex | Self::FivePointFourPro => Some(ReasoningEffort::Medium),
             _ => None,
         }
     }
@@ -241,7 +255,10 @@ impl Model {
                 supports_chat_completions,
                 ..
             } => *supports_chat_completions,
-            Self::FiveCodex | Self::FivePointTwoCodex | Self::FivePointThreeCodex => false,
+            Self::FiveCodex
+            | Self::FivePointTwoCodex
+            | Self::FivePointThreeCodex
+            | Self::FivePointFourPro => false,
             _ => true,
         }
     }
@@ -263,6 +280,8 @@ impl Model {
             | Self::FivePointTwo
             | Self::FivePointTwoCodex
             | Self::FivePointThreeCodex
+            | Self::FivePointFour
+            | Self::FivePointFourPro
             | Self::FiveNano => true,
             Self::O1 | Self::O3 | Self::O3Mini | Model::Custom { .. } => false,
         }

crates/open_ai/src/responses.rs 🔗

@@ -78,6 +78,16 @@ pub enum ResponseInputContent {
 #[derive(Serialize, Debug)]
 pub struct ReasoningConfig {
     pub effort: ReasoningEffort,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub summary: Option<ReasoningSummaryMode>,
+}
+
+#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum ReasoningSummaryMode {
+    Auto,
+    Concise,
+    Detailed,
 }
 
 #[derive(Serialize, Debug)]
@@ -150,6 +160,30 @@ pub enum StreamEvent {
         content_index: Option<usize>,
         text: String,
     },
+    #[serde(rename = "response.reasoning_summary_part.added")]
+    ReasoningSummaryPartAdded {
+        item_id: String,
+        output_index: usize,
+        summary_index: usize,
+    },
+    #[serde(rename = "response.reasoning_summary_text.delta")]
+    ReasoningSummaryTextDelta {
+        item_id: String,
+        output_index: usize,
+        delta: String,
+    },
+    #[serde(rename = "response.reasoning_summary_text.done")]
+    ReasoningSummaryTextDone {
+        item_id: String,
+        output_index: usize,
+        text: String,
+    },
+    #[serde(rename = "response.reasoning_summary_part.done")]
+    ReasoningSummaryPartDone {
+        item_id: String,
+        output_index: usize,
+        summary_index: usize,
+    },
     #[serde(rename = "response.function_call_arguments.delta")]
     FunctionCallArgumentsDelta {
         item_id: String,
@@ -219,6 +253,25 @@ pub struct ResponseUsage {
 pub enum ResponseOutputItem {
     Message(ResponseOutputMessage),
     FunctionCall(ResponseFunctionToolCall),
+    Reasoning(ResponseReasoningItem),
+    #[serde(other)]
+    Unknown,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+pub struct ResponseReasoningItem {
+    #[serde(default)]
+    pub id: Option<String>,
+    #[serde(default)]
+    pub summary: Vec<ReasoningSummaryPart>,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum ReasoningSummaryPart {
+    SummaryText {
+        text: String,
+    },
     #[serde(other)]
     Unknown,
 }
@@ -356,6 +409,21 @@ pub async fn stream_response(
                                     });
                                 }
                             }
+                            ResponseOutputItem::Reasoning(reasoning) => {
+                                if let Some(ref item_id) = reasoning.id {
+                                    for part in &reasoning.summary {
+                                        if let ReasoningSummaryPart::SummaryText { text } = part {
+                                            all_events.push(
+                                                StreamEvent::ReasoningSummaryTextDelta {
+                                                    item_id: item_id.clone(),
+                                                    output_index,
+                                                    delta: text.clone(),
+                                                },
+                                            );
+                                        }
+                                    }
+                                }
+                            }
                             ResponseOutputItem::Unknown => {}
                         }
 

crates/open_path_prompt/src/file_finder_settings.rs 🔗

@@ -8,6 +8,7 @@ pub struct FileFinderSettings {
     pub modal_max_width: FileFinderWidth,
     pub skip_focus_for_active_in_search: bool,
     pub include_ignored: Option<bool>,
+    pub include_channels: bool,
 }
 
 impl Settings for FileFinderSettings {
@@ -23,6 +24,7 @@ impl Settings for FileFinderSettings {
                 settings::IncludeIgnoredContent::Indexed => Some(false),
                 settings::IncludeIgnoredContent::Smart => None,
             },
+            include_channels: file_finder.include_channels.unwrap(),
         }
     }
 }

crates/outline/Cargo.toml 🔗

@@ -38,6 +38,4 @@ project = { workspace = true, features = ["test-support"] }
 rope.workspace = true
 serde_json.workspace = true
 settings = { workspace = true, features = ["test-support"] }
-tree-sitter-rust.workspace = true
-tree-sitter-typescript.workspace = true
 workspace = { workspace = true, features = ["test-support"] }

crates/outline_panel/src/outline_panel.rs 🔗

@@ -1143,7 +1143,7 @@ impl OutlinePanel {
                             .excerpts_for_buffer(buffer.read(cx).remote_id(), cx)
                     })
                     .and_then(|excerpts| {
-                        let (excerpt_id, excerpt_range) = excerpts.first()?;
+                        let (excerpt_id, _, excerpt_range) = excerpts.first()?;
                         multi_buffer_snapshot
                             .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start)
                     })

crates/panel/src/panel.rs 🔗

@@ -52,7 +52,6 @@ pub fn panel_button(label: impl Into<SharedString>) -> ui::Button {
     let id = ElementId::Name(label.to_lowercase().replace(' ', "_").into());
     ui::Button::new(id, label)
         .label_size(ui::LabelSize::Small)
-        .icon_size(ui::IconSize::Small)
         // TODO: Change this once we use on_surface_bg in button_like
         .layer(ui::ElevationIndex::ModalSurface)
         .size(ui::ButtonSize::Compact)

crates/platform_title_bar/src/platform_title_bar.rs 🔗

@@ -30,9 +30,8 @@ pub struct PlatformTitleBar {
     platform_style: PlatformStyle,
     children: SmallVec<[AnyElement; 2]>,
     should_move: bool,
+    background_color: Option<Hsla>,
     system_window_tabs: Entity<SystemWindowTabs>,
-    workspace_sidebar_open: bool,
-    sidebar_has_notifications: bool,
 }
 
 impl PlatformTitleBar {
@@ -45,13 +44,16 @@ impl PlatformTitleBar {
             platform_style,
             children: SmallVec::new(),
             should_move: false,
+            background_color: None,
             system_window_tabs,
-            workspace_sidebar_open: false,
-            sidebar_has_notifications: false,
         }
     }
 
     pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
+        if let Some(background_color) = self.background_color {
+            return background_color;
+        }
+
         if cfg!(any(target_os = "linux", target_os = "freebsd")) {
             if window.is_window_active() && !self.should_move {
                 cx.theme().colors().title_bar_background
@@ -70,30 +72,12 @@ impl PlatformTitleBar {
         self.children = children.into_iter().collect();
     }
 
-    pub fn init(cx: &mut App) {
-        SystemWindowTabs::init(cx);
-    }
-
-    pub fn is_workspace_sidebar_open(&self) -> bool {
-        self.workspace_sidebar_open
-    }
-
-    pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
-        self.workspace_sidebar_open = open;
-        cx.notify();
-    }
-
-    pub fn sidebar_has_notifications(&self) -> bool {
-        self.sidebar_has_notifications
+    pub fn set_background_color(&mut self, background_color: Option<Hsla>) {
+        self.background_color = background_color;
     }
 
-    pub fn set_sidebar_has_notifications(
-        &mut self,
-        has_notifications: bool,
-        cx: &mut Context<Self>,
-    ) {
-        self.sidebar_has_notifications = has_notifications;
-        cx.notify();
+    pub fn init(cx: &mut App) {
+        SystemWindowTabs::init(cx);
     }
 
     pub fn is_multi_workspace_enabled(cx: &App) -> bool {
@@ -110,9 +94,6 @@ impl Render for PlatformTitleBar {
         let close_action = Box::new(workspace::CloseWindow);
         let children = mem::take(&mut self.children);
 
-        let is_multiworkspace_sidebar_open =
-            PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open();
-
         let title_bar = h_flex()
             .window_control_area(WindowControlArea::Drag)
             .w_full()
@@ -161,9 +142,7 @@ impl Render for PlatformTitleBar {
             .map(|this| {
                 if window.is_fullscreen() {
                     this.pl_2()
-                } else if self.platform_style == PlatformStyle::Mac
-                    && !is_multiworkspace_sidebar_open
-                {
+                } else if self.platform_style == PlatformStyle::Mac {
                     this.pl(px(TRAFFIC_LIGHT_PADDING))
                 } else {
                     this.pl_2()
@@ -175,10 +154,9 @@ impl Render for PlatformTitleBar {
                     .when(!(tiling.top || tiling.right), |el| {
                         el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
                     })
-                    .when(
-                        !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open,
-                        |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING),
-                    )
+                    .when(!(tiling.top || tiling.left), |el| {
+                        el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
+                    })
                     // this border is to avoid a transparent gap in the rounded corners
                     .mt(px(-1.))
                     .mb(px(-1.))

crates/project/Cargo.toml 🔗

@@ -31,7 +31,6 @@ test-support = [
     "worktree/test-support",
     "gpui/test-support",
     "dap/test-support",
-    "dap_adapters/test-support",
 ]
 
 [dependencies]
@@ -105,12 +104,10 @@ tracing.workspace = true
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
 encoding_rs.workspace = true
-db = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
 context_server = { workspace = true, features = ["test-support"] }
 buffer_diff = { workspace = true, features = ["test-support"] }
 dap = { workspace = true, features = ["test-support"] }
-dap_adapters = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 git2.workspace = true
 gpui = { workspace = true, features = ["test-support"] }

crates/project/src/agent_registry_store.rs 🔗

@@ -147,6 +147,22 @@ impl AgentRegistryStore {
             .map(|store| store.0.clone())
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn init_test_global(cx: &mut App, agents: Vec<RegistryAgent>) -> Entity<Self> {
+        let fs: Arc<dyn Fs> = fs::FakeFs::new(cx.background_executor().clone());
+        let store = cx.new(|_cx| Self {
+            fs,
+            http_client: http_client::FakeHttpClient::with_404_response(),
+            agents,
+            is_fetching: false,
+            fetch_error: None,
+            pending_refresh: None,
+            last_refresh: None,
+        });
+        cx.set_global(GlobalAgentRegistryStore(store.clone()));
+        store
+    }
+
     pub fn agents(&self) -> &[RegistryAgent] {
         &self.agents
     }

crates/project/src/agent_server_store.rs 🔗

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

crates/project/src/buffer_store.rs 🔗

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

crates/project/src/context_server_store.rs 🔗

@@ -222,6 +222,7 @@ pub struct ContextServerStore {
     update_servers_task: Option<Task<Result<()>>>,
     context_server_factory: Option<ContextServerFactory>,
     needs_server_update: bool,
+    ai_disabled: bool,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -377,23 +378,42 @@ impl ContextServerStore {
         cx: &mut Context<Self>,
     ) -> Self {
         let mut subscriptions = vec![cx.observe_global::<SettingsStore>(move |this, cx| {
+            let ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
+            let ai_was_disabled = this.ai_disabled;
+            this.ai_disabled = ai_disabled;
+
             let settings =
                 &Self::resolve_project_settings(&this.worktree_store, cx).context_servers;
-            if &this.context_server_settings == settings {
+            let settings_changed = &this.context_server_settings != settings;
+
+            if settings_changed {
+                this.context_server_settings = settings.clone();
+            }
+
+            // When AI is disabled, stop all running servers
+            if ai_disabled {
+                let server_ids: Vec<_> = this.servers.keys().cloned().collect();
+                for id in server_ids {
+                    this.stop_server(&id, cx).log_err();
+                }
                 return;
             }
-            this.context_server_settings = settings.clone();
-            if maintain_server_loop {
+
+            // Trigger updates if AI was re-enabled or settings changed
+            if maintain_server_loop && (ai_was_disabled || settings_changed) {
                 this.available_context_servers_changed(cx);
             }
         })];
 
         if maintain_server_loop {
             subscriptions.push(cx.observe(&registry, |this, _registry, cx| {
-                this.available_context_servers_changed(cx);
+                if !DisableAiSettings::get_global(cx).disable_ai {
+                    this.available_context_servers_changed(cx);
+                }
             }));
         }
 
+        let ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
         let mut this = Self {
             state,
             _subscriptions: subscriptions,
@@ -404,12 +424,13 @@ impl ContextServerStore {
             project: weak_project,
             registry,
             needs_server_update: false,
+            ai_disabled,
             servers: HashMap::default(),
             server_ids: Default::default(),
             update_servers_task: None,
             context_server_factory,
         };
-        if maintain_server_loop {
+        if maintain_server_loop && !DisableAiSettings::get_global(cx).disable_ai {
             this.available_context_servers_changed(cx);
         }
         this

crates/project/src/debugger/session.rs 🔗

@@ -2187,21 +2187,27 @@ impl Session {
             self.capabilities.supports_restart_request.unwrap_or(false) && !self.is_terminated();
 
         self.restart_task = Some(cx.spawn(async move |this, cx| {
-            let _ = this.update(cx, |session, cx| {
+            this.update(cx, |session, cx| {
                 if supports_dap_restart {
-                    session
-                        .request(
-                            RestartCommand {
-                                raw: args.unwrap_or(Value::Null),
-                            },
-                            Self::fallback_to_manual_restart,
-                            cx,
-                        )
-                        .detach();
+                    session.request(
+                        RestartCommand {
+                            raw: args.unwrap_or(Value::Null),
+                        },
+                        Self::fallback_to_manual_restart,
+                        cx,
+                    )
                 } else {
                     cx.emit(SessionStateEvent::Restart);
+                    Task::ready(None)
                 }
-            });
+            })
+            .unwrap_or_else(|_| Task::ready(None))
+            .await;
+
+            this.update(cx, |session, _cx| {
+                session.restart_task = None;
+            })
+            .ok();
         }));
     }
 

crates/project/src/git_store.rs 🔗

@@ -293,6 +293,7 @@ pub struct RepositorySnapshot {
     pub remote_origin_url: Option<String>,
     pub remote_upstream_url: Option<String>,
     pub stash_entries: GitStash,
+    pub linked_worktrees: Arc<[GitWorktree]>,
 }
 
 type JobId = u64;
@@ -429,6 +430,7 @@ pub enum RepositoryEvent {
     StatusesChanged,
     BranchChanged,
     StashEntriesChanged,
+    GitWorktreeListChanged,
     PendingOpsChanged { pending_ops: SumTree<PendingOps> },
     GraphEvent((LogSource, LogOrder), GitGraphEvent),
 }
@@ -578,6 +580,8 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_git_clone);
         client.add_entity_request_handler(Self::handle_get_worktrees);
         client.add_entity_request_handler(Self::handle_create_worktree);
+        client.add_entity_request_handler(Self::handle_remove_worktree);
+        client.add_entity_request_handler(Self::handle_rename_worktree);
     }
 
     pub fn is_local(&self) -> bool {
@@ -2384,6 +2388,44 @@ impl GitStore {
         Ok(proto::Ack {})
     }
 
+    async fn handle_remove_worktree(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitRemoveWorktree>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let path = PathBuf::from(envelope.payload.path);
+        let force = envelope.payload.force;
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.remove_worktree(path, force)
+            })
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_rename_worktree(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitRenameWorktree>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let old_path = PathBuf::from(envelope.payload.old_path);
+        let new_path = PathBuf::from(envelope.payload.new_path);
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.rename_worktree(old_path, new_path)
+            })
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
     async fn handle_get_branches(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::GitGetBranches>,
@@ -3535,6 +3577,7 @@ impl RepositorySnapshot {
             remote_origin_url: None,
             remote_upstream_url: None,
             stash_entries: Default::default(),
+            linked_worktrees: Arc::from([]),
             path_style,
         }
     }
@@ -3573,6 +3616,11 @@ impl RepositorySnapshot {
             original_repo_abs_path: Some(
                 self.original_repo_abs_path.to_string_lossy().into_owned(),
             ),
+            linked_worktrees: self
+                .linked_worktrees
+                .iter()
+                .map(worktree_to_proto)
+                .collect(),
         }
     }
 
@@ -3649,9 +3697,18 @@ impl RepositorySnapshot {
             original_repo_abs_path: Some(
                 self.original_repo_abs_path.to_string_lossy().into_owned(),
             ),
+            linked_worktrees: self
+                .linked_worktrees
+                .iter()
+                .map(worktree_to_proto)
+                .collect(),
         }
     }
 
+    pub fn linked_worktrees(&self) -> &[GitWorktree] {
+        &self.linked_worktrees
+    }
+
     pub fn status(&self) -> impl Iterator<Item = StatusEntry> + '_ {
         self.statuses_by_path.iter().cloned()
     }
@@ -5731,6 +5788,7 @@ impl Repository {
     }
 
     pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
+        let id = self.id;
         self.send_job(
             Some(format!("git worktree remove: {}", path.display()).into()),
             move |repo, _cx| async move {
@@ -5738,10 +5796,47 @@ impl Repository {
                     RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
                         backend.remove_worktree(path, force).await
                     }
-                    RepositoryState::Remote(_) => {
-                        anyhow::bail!(
-                            "Removing worktrees on remote repositories is not yet supported"
-                        )
+                    RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                        client
+                            .request(proto::GitRemoveWorktree {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                path: path.to_string_lossy().to_string(),
+                                force,
+                            })
+                            .await?;
+
+                        Ok(())
+                    }
+                }
+            },
+        )
+    }
+
+    pub fn rename_worktree(
+        &mut self,
+        old_path: PathBuf,
+        new_path: PathBuf,
+    ) -> oneshot::Receiver<Result<()>> {
+        let id = self.id;
+        self.send_job(
+            Some(format!("git worktree move: {}", old_path.display()).into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                        backend.rename_worktree(old_path, new_path).await
+                    }
+                    RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                        client
+                            .request(proto::GitRenameWorktree {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                old_path: old_path.to_string_lossy().to_string(),
+                                new_path: new_path.to_string_lossy().to_string(),
+                            })
+                            .await?;
+
+                        Ok(())
                     }
                 }
             },
@@ -6067,6 +6162,15 @@ impl Repository {
             cx.emit(RepositoryEvent::StashEntriesChanged)
         }
         self.snapshot.stash_entries = new_stash_entries;
+        let new_linked_worktrees: Arc<[GitWorktree]> = update
+            .linked_worktrees
+            .iter()
+            .map(proto_to_worktree)
+            .collect();
+        if *self.snapshot.linked_worktrees != *new_linked_worktrees {
+            cx.emit(RepositoryEvent::GitWorktreeListChanged);
+        }
+        self.snapshot.linked_worktrees = new_linked_worktrees;
         self.snapshot.remote_upstream_url = update.remote_upstream_url;
         self.snapshot.remote_origin_url = update.remote_origin_url;
 
@@ -6823,14 +6927,20 @@ async fn compute_snapshot(
         }))
         .boxed()
     };
-    let (statuses, diff_stats) = futures::future::try_join(
+    let (statuses, diff_stats, all_worktrees) = futures::future::try_join3(
         backend.status(&[RepoPath::from_rel_path(
             &RelPath::new(".".as_ref(), PathStyle::local()).unwrap(),
         )]),
         diff_stat_future,
+        backend.worktrees(),
     )
     .await?;
 
+    let linked_worktrees: Arc<[GitWorktree]> = all_worktrees
+        .into_iter()
+        .filter(|wt| wt.path != *work_directory_abs_path)
+        .collect();
+
     let diff_stat_map: HashMap<&RepoPath, DiffStat> =
         diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect();
     let stash_entries = backend.stash_entries().await?;
@@ -6860,6 +6970,10 @@ async fn compute_snapshot(
         events.push(RepositoryEvent::BranchChanged);
     }
 
+    if *linked_worktrees != *prev_snapshot.linked_worktrees {
+        events.push(RepositoryEvent::GitWorktreeListChanged);
+    }
+
     let remote_origin_url = backend.remote_url("origin").await;
     let remote_upstream_url = backend.remote_url("upstream").await;
 
@@ -6876,6 +6990,7 @@ async fn compute_snapshot(
         remote_origin_url,
         remote_upstream_url,
         stash_entries,
+        linked_worktrees,
     };
 
     Ok((snapshot, events))

crates/project/src/image_store.rs 🔗

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

crates/project/src/lsp_store.rs 🔗

@@ -1778,9 +1778,10 @@ impl LocalLspStore {
                                 }
                             })
                         }
-                        settings::LanguageServerFormatterSpecifier::Current => {
-                            adapters_and_servers.first().map(|e| e.1.clone())
-                        }
+                        settings::LanguageServerFormatterSpecifier::Current => adapters_and_servers
+                            .iter()
+                            .find(|(_, server)| Self::server_supports_formatting(server))
+                            .map(|(_, server)| server.clone()),
                     };
 
                     let Some(language_server) = language_server else {
@@ -2285,6 +2286,14 @@ impl LocalLspStore {
         }
     }
 
+    fn server_supports_formatting(server: &Arc<LanguageServer>) -> bool {
+        let capabilities = server.capabilities();
+        let formatting = capabilities.document_formatting_provider.as_ref();
+        let range_formatting = capabilities.document_range_formatting_provider.as_ref();
+        matches!(formatting, Some(p) if *p != OneOf::Left(false))
+            || matches!(range_formatting, Some(p) if *p != OneOf::Left(false))
+    }
+
     async fn format_via_lsp(
         this: &WeakEntity<LspStore>,
         buffer: &Entity<Buffer>,
@@ -3954,10 +3963,7 @@ impl BufferLspData {
         self.inlay_hints.remove_server_data(for_server);
 
         if let Some(semantic_tokens) = &mut self.semantic_tokens {
-            semantic_tokens.raw_tokens.servers.remove(&for_server);
-            semantic_tokens
-                .latest_invalidation_requests
-                .remove(&for_server);
+            semantic_tokens.remove_server_data(for_server);
         }
 
         if let Some(folding_ranges) = &mut self.folding_ranges {
@@ -4420,7 +4426,7 @@ impl LspStore {
         cx: &mut Context<Self>,
     ) {
         match event {
-            language::BufferEvent::Edited => {
+            language::BufferEvent::Edited { .. } => {
                 self.on_buffer_edited(buffer, cx);
             }
 
@@ -4895,7 +4901,7 @@ impl LspStore {
         buffer: &Entity<Buffer>,
         mut check: F,
         cx: &App,
-    ) -> Vec<lsp::LanguageServerId>
+    ) -> Vec<(lsp::LanguageServerId, lsp::LanguageServerName)>
     where
         F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool,
     {
@@ -4925,7 +4931,7 @@ impl LspStore {
                     .map(|c| (server_id, server_name, c))
             })
             .filter(|(_, server_name, capabilities)| check(server_name, capabilities))
-            .map(|(server_id, _, _)| *server_id)
+            .map(|(server_id, server_name, _)| (*server_id, server_name.clone()))
             .collect()
     }
 
@@ -6123,23 +6129,13 @@ impl LspStore {
 
             let language = buffer.read(cx).language().cloned();
 
-            // In the future, we should provide project guests with the names of LSP adapters,
-            // so that they can use the correct LSP adapter when computing labels. For now,
-            // guests just use the first LSP adapter associated with the buffer's language.
-            let lsp_adapter = language.as_ref().and_then(|language| {
-                language_registry
-                    .lsp_adapters(&language.name())
-                    .first()
-                    .cloned()
-            });
-
             let buffer = buffer.clone();
 
             cx.spawn(async move |this, cx| {
                 let requests = join_all(
                     capable_lsps
                         .into_iter()
-                        .map(|id| {
+                        .map(|(id, server_name)| {
                             let request = GetCompletions {
                                 position,
                                 context: context.clone(),
@@ -6147,7 +6143,14 @@ impl LspStore {
                             };
                             let buffer = buffer.clone();
                             let language = language.clone();
-                            let lsp_adapter = lsp_adapter.clone();
+                            let lsp_adapter = language.as_ref().and_then(|language| {
+                                let adapters = language_registry.lsp_adapters(&language.name());
+                                adapters
+                                    .iter()
+                                    .find(|adapter| adapter.name() == server_name)
+                                    .or_else(|| adapters.first())
+                                    .cloned()
+                            });
                             let upstream_client = upstream_client.clone();
                             let response = this
                                 .update(cx, |this, cx| {

crates/project/src/lsp_store/json_language_server_ext.rs 🔗

@@ -42,8 +42,8 @@ impl lsp::notification::Notification for SchemaContentsChanged {
     type Params = String;
 }
 
-pub fn notify_schema_changed(lsp_store: Entity<LspStore>, uri: String, cx: &App) {
-    zlog::trace!(LOGGER => "Notifying schema changed for URI: {:?}", uri);
+pub fn notify_schemas_changed(lsp_store: Entity<LspStore>, uris: &[String], cx: &App) {
+    zlog::trace!(LOGGER => "Notifying schema changes for URIs: {:?}", uris);
     let servers = lsp_store.read_with(cx, |lsp_store, _| {
         let mut servers = Vec::new();
         let Some(local) = lsp_store.as_local() else {
@@ -63,16 +63,18 @@ pub fn notify_schema_changed(lsp_store: Entity<LspStore>, uri: String, cx: &App)
         servers
     });
     for server in servers {
-        zlog::trace!(LOGGER => "Notifying server {NAME} (id {ID:?}) of schema change for URI: {uri:?}",
-            NAME = server.name(),
-            ID = server.server_id()
-        );
-        if let Err(error) = server.notify::<SchemaContentsChanged>(uri.clone()) {
-            zlog::error!(
-                LOGGER => "Failed to notify server {NAME} (id {ID:?}) of schema change for URI {uri:?}: {error:#}",
-                    NAME = server.name(),
-                    ID = server.server_id(),
+        for uri in uris {
+            zlog::trace!(LOGGER => "Notifying server {NAME} (id {ID:?}) of schema change for URI: {uri:?}",
+                NAME = server.name(),
+                ID = server.server_id()
             );
+            if let Err(error) = server.notify::<SchemaContentsChanged>(uri.clone()) {
+                zlog::error!(
+                    LOGGER => "Failed to notify server {NAME} (id {ID:?}) of schema change for URI {uri:?}: {error:#}",
+                        NAME = server.name(),
+                        ID = server.server_id(),
+                );
+            }
         }
     }
 }

crates/project/src/lsp_store/semantic_tokens.rs 🔗

@@ -585,8 +585,7 @@ async fn raw_to_buffer_semantic_tokens(
                     }
 
                     Some(BufferSemanticToken {
-                        range: buffer_snapshot.anchor_before(start)
-                            ..buffer_snapshot.anchor_after(end),
+                        range: buffer_snapshot.anchor_range_around(start..end),
                         token_type: token.token_type,
                         token_modifiers: token.token_modifiers,
                     })
@@ -611,6 +610,14 @@ pub struct SemanticTokensData {
     update: Option<(Global, SemanticTokensTask)>,
 }
 
+impl SemanticTokensData {
+    pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) {
+        self.raw_tokens.servers.remove(&server_id);
+        self.latest_invalidation_requests.remove(&server_id);
+        self.update = None;
+    }
+}
+
 /// All the semantic token tokens for a buffer.
 ///
 /// This aggregates semantic tokens from multiple language servers in a specific order.

crates/project/src/project.rs 🔗

@@ -3636,11 +3636,11 @@ impl Project {
         event: &BufferEvent,
         cx: &mut Context<Self>,
     ) -> Option<()> {
-        if matches!(event, BufferEvent::Edited | BufferEvent::Reloaded) {
+        if matches!(event, BufferEvent::Edited { .. } | BufferEvent::Reloaded) {
             self.request_buffer_diff_recalculation(&buffer, cx);
         }
 
-        if matches!(event, BufferEvent::Edited) {
+        if matches!(event, BufferEvent::Edited { .. }) {
             cx.emit(Event::BufferEdited);
         }
 

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

@@ -8,10 +8,11 @@ use project::context_server_store::*;
 use project::project_settings::ContextServerSettings;
 use project::worktree_store::WorktreeStore;
 use project::{
-    FakeFs, Project, context_server_store::registry::ContextServerDescriptor,
+    DisableAiSettings, FakeFs, Project, context_server_store::registry::ContextServerDescriptor,
     project_settings::ProjectSettings,
 };
 use serde_json::json;
+use settings::settings_content::SaturatingBool;
 use settings::{ContextServerCommand, Settings, SettingsStore};
 use std::sync::Arc;
 use std::{cell::RefCell, path::PathBuf, rc::Rc};
@@ -553,6 +554,116 @@ async fn test_context_server_enabled_disabled(cx: &mut TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_context_server_respects_disable_ai(cx: &mut TestAppContext) {
+    const SERVER_1_ID: &str = "mcp-1";
+
+    let server_1_id = ContextServerId(SERVER_1_ID.into());
+
+    // Set up SettingsStore with disable_ai: true in user settings BEFORE creating project
+    cx.update(|cx| {
+        let settings_store = SettingsStore::test(cx);
+        cx.set_global(settings_store);
+        DisableAiSettings::register(cx);
+        // Set disable_ai via user settings (not override_global) so it persists through recompute_values
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |content| {
+                content.project.disable_ai = Some(SaturatingBool(true));
+            });
+        });
+    });
+
+    // Now create the project (ContextServerStore will see disable_ai = true)
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(path!("/test"), json!({"code.rs": ""})).await;
+    let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+
+    let executor = cx.executor();
+    let store = project.read_with(cx, |project, _| project.context_server_store());
+    store.update(cx, |store, _| {
+        store.set_context_server_factory(Box::new(move |id, _| {
+            Arc::new(ContextServer::new(
+                id.clone(),
+                Arc::new(create_fake_transport(id.0.to_string(), executor.clone())),
+            ))
+        }));
+    });
+
+    set_context_server_configuration(
+        vec![(
+            server_1_id.0.clone(),
+            settings::ContextServerSettingsContent::Stdio {
+                enabled: true,
+                remote: false,
+                command: ContextServerCommand {
+                    path: "somebinary".into(),
+                    args: vec!["arg".to_string()],
+                    env: None,
+                    timeout: None,
+                },
+            },
+        )],
+        cx,
+    );
+
+    cx.run_until_parked();
+
+    // Verify that no server started because AI is disabled
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_1_id),
+            None,
+            "Server should not start when disable_ai is true"
+        );
+    });
+
+    // Enable AI and verify server starts
+    {
+        let _server_events = assert_server_events(
+            &store,
+            vec![
+                (server_1_id.clone(), ContextServerStatus::Starting),
+                (server_1_id.clone(), ContextServerStatus::Running),
+            ],
+            cx,
+        );
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |content| {
+                    content.project.disable_ai = Some(SaturatingBool(false));
+                });
+            });
+        });
+        cx.run_until_parked();
+    }
+
+    // Disable AI again and verify server stops
+    {
+        let _server_events = assert_server_events(
+            &store,
+            vec![(server_1_id.clone(), ContextServerStatus::Stopped)],
+            cx,
+        );
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |content| {
+                    content.project.disable_ai = Some(SaturatingBool(true));
+                });
+            });
+        });
+        cx.run_until_parked();
+    }
+
+    // Verify server is stopped
+    cx.update(|cx| {
+        assert_eq!(
+            store.read(cx).status_for_server(&server_1_id),
+            Some(ContextServerStatus::Stopped),
+            "Server should be stopped when disable_ai is true"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_server_ids_includes_disabled_servers(cx: &mut TestAppContext) {
     const ENABLED_SERVER_ID: &str = "enabled-server";

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

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

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

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

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

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

crates/project_panel/Cargo.toml 🔗

@@ -54,7 +54,6 @@ criterion.workspace = true
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
-remote_connection = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
 tempfile.workspace = true
 workspace = { workspace = true, features = ["test-support"] }

crates/project_panel/src/project_panel.rs 🔗

@@ -2371,6 +2371,11 @@ impl ProjectPanel {
             }
             let answer = if !skip_prompt {
                 let operation = if trash { "Trash" } else { "Delete" };
+                let message_start = if trash {
+                    "Do you want to trash"
+                } else {
+                    "Are you sure you want to permanently delete"
+                };
                 let prompt = match file_paths.first() {
                     Some((_, path)) if file_paths.len() == 1 => {
                         let unsaved_warning = if dirty_buffers > 0 {
@@ -2379,7 +2384,7 @@ impl ProjectPanel {
                             ""
                         };
 
-                        format!("{operation} {path}?{unsaved_warning}")
+                        format!("{message_start} {path}?{unsaved_warning}")
                     }
                     _ => {
                         const CUTOFF_POINT: usize = 10;
@@ -2411,14 +2416,20 @@ impl ProjectPanel {
                         };
 
                         format!(
-                            "Do you want to {} the following {} files?\n{}{unsaved_warning}",
-                            operation.to_lowercase(),
+                            "{message_start} the following {} files?\n{}{unsaved_warning}",
                             file_paths.len(),
                             names.join("\n")
                         )
                     }
                 };
-                Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx))
+                let detail = (!trash).then_some("This cannot be undone.");
+                Some(window.prompt(
+                    PromptLevel::Info,
+                    &prompt,
+                    detail,
+                    &[operation, "Cancel"],
+                    cx,
+                ))
             } else {
                 None
             };
@@ -3403,8 +3414,7 @@ impl ProjectPanel {
         _: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
-            let path = worktree.read(cx).absolutize(&entry.path);
+        if let Some(path) = self.reveal_in_file_manager_path(cx) {
             self.project
                 .update(cx, |project, cx| project.reveal_path(&path, cx));
         }
@@ -3761,6 +3771,20 @@ impl ProjectPanel {
         }
         Some((worktree, entry))
     }
+
+    fn reveal_in_file_manager_path(&self, cx: &App) -> Option<PathBuf> {
+        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
+            return Some(worktree.read(cx).absolutize(&entry.path));
+        }
+
+        let root_entry_id = self.state.last_worktree_root_id?;
+        let project = self.project.read(cx);
+        let worktree = project.worktree_for_entry(root_entry_id, cx)?;
+        let worktree = worktree.read(cx);
+        let root_entry = worktree.entry_for_id(root_entry_id)?;
+        Some(worktree.absolutize(&root_entry.path))
+    }
+
     fn selected_entry_handle<'a>(
         &self,
         cx: &'a App,
@@ -4415,16 +4439,24 @@ impl ProjectPanel {
                 return;
             }
 
+            let workspace = self.workspace.clone();
             if folded_selection_info.is_empty() {
                 for (_, task) in move_tasks {
-                    task.detach_and_log_err(cx);
+                    let workspace = workspace.clone();
+                    cx.spawn_in(window, async move |_, mut cx| {
+                        task.await.notify_workspace_async_err(workspace, &mut cx);
+                    })
+                    .detach();
                 }
             } else {
-                cx.spawn_in(window, async move |project_panel, cx| {
+                cx.spawn_in(window, async move |project_panel, mut cx| {
                     // Await all move tasks and collect successful results
                     let mut move_results: Vec<(ProjectEntryId, Entry)> = Vec::new();
                     for (entry_id, task) in move_tasks {
-                        if let Some(CreatedEntry::Included(new_entry)) = task.await.log_err() {
+                        if let Some(CreatedEntry::Included(new_entry)) = task
+                            .await
+                            .notify_workspace_async_err(workspace.clone(), &mut cx)
+                        {
                             move_results.push((entry_id, new_entry));
                         }
                     }
@@ -6309,6 +6341,7 @@ impl Render for ProjectPanel {
         let panel_settings = ProjectPanelSettings::get_global(cx);
         let indent_size = panel_settings.indent_size;
         let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always;
+        let horizontal_scroll = panel_settings.scrollbar.horizontal_scroll;
         let show_sticky_entries = {
             if panel_settings.sticky_scroll {
                 let is_scrollable = self.scroll_handle.is_scrollable();
@@ -6681,10 +6714,14 @@ impl Render for ProjectPanel {
                                 })
                             })
                             .with_sizing_behavior(ListSizingBehavior::Infer)
-                            .with_horizontal_sizing_behavior(
-                                ListHorizontalSizingBehavior::Unconstrained,
-                            )
-                            .with_width_from_item(self.state.max_width_item_index)
+                            .with_horizontal_sizing_behavior(if horizontal_scroll {
+                                ListHorizontalSizingBehavior::Unconstrained
+                            } else {
+                                ListHorizontalSizingBehavior::FitList
+                            })
+                            .when(horizontal_scroll, |list| {
+                                list.with_width_from_item(self.state.max_width_item_index)
+                            })
                             .track_scroll(&self.scroll_handle),
                         )
                         .child(
@@ -6845,13 +6882,17 @@ impl Render for ProjectPanel {
                         .size_full(),
                 )
                 .custom_scrollbars(
-                    Scrollbars::for_settings::<ProjectPanelSettings>()
-                        .tracked_scroll_handle(&self.scroll_handle)
-                        .with_track_along(
-                            ScrollAxes::Horizontal,
-                            cx.theme().colors().panel_background,
-                        )
-                        .notify_content(),
+                    {
+                        let mut scrollbars = Scrollbars::for_settings::<ProjectPanelSettings>()
+                            .tracked_scroll_handle(&self.scroll_handle);
+                        if horizontal_scroll {
+                            scrollbars = scrollbars.with_track_along(
+                                ScrollAxes::Horizontal,
+                                cx.theme().colors().panel_background,
+                            );
+                        }
+                        scrollbars.notify_content()
+                    },
                     window,
                     cx,
                 )

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -49,6 +49,11 @@ pub struct ScrollbarSettings {
     ///
     /// Default: inherits editor scrollbar settings
     pub show: Option<ShowScrollbar>,
+    /// Whether to allow horizontal scrolling in the project panel.
+    /// When false, the view is locked to the leftmost position and long file names are clipped.
+    ///
+    /// Default: true
+    pub horizontal_scroll: bool,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -111,8 +116,12 @@ impl Settings for ProjectPanelSettings {
             auto_fold_dirs: project_panel.auto_fold_dirs.unwrap(),
             bold_folder_labels: project_panel.bold_folder_labels.unwrap(),
             starts_open: project_panel.starts_open.unwrap(),
-            scrollbar: ScrollbarSettings {
-                show: project_panel.scrollbar.unwrap().show.map(Into::into),
+            scrollbar: {
+                let scrollbar = project_panel.scrollbar.unwrap();
+                ScrollbarSettings {
+                    show: scrollbar.show.map(Into::into),
+                    horizontal_scroll: scrollbar.horizontal_scroll.unwrap(),
+                }
             },
             show_diagnostics: project_panel.show_diagnostics.unwrap(),
             hide_root: project_panel.hide_root.unwrap(),

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -4412,6 +4412,90 @@ async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppCo
     );
 }
 
+#[gpui::test]
+async fn test_dragging_same_named_files_preserves_one_source_on_conflict(
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "dir_a": {
+                "shared.txt": "from a"
+            },
+            "dir_b": {
+                "shared.txt": "from b"
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    panel.update_in(cx, |panel, window, cx| {
+        let (root_entry_id, worktree_id, entry_a_id, entry_b_id) = {
+            let worktree = panel.project.read(cx).visible_worktrees(cx).next().unwrap();
+            let worktree = worktree.read(cx);
+            let root_entry_id = worktree.root_entry().unwrap().id;
+            let worktree_id = worktree.id();
+            let entry_a_id = worktree
+                .entry_for_path(rel_path("dir_a/shared.txt"))
+                .unwrap()
+                .id;
+            let entry_b_id = worktree
+                .entry_for_path(rel_path("dir_b/shared.txt"))
+                .unwrap()
+                .id;
+            (root_entry_id, worktree_id, entry_a_id, entry_b_id)
+        };
+
+        let drag = DraggedSelection {
+            active_selection: SelectedEntry {
+                worktree_id,
+                entry_id: entry_a_id,
+            },
+            marked_selections: Arc::new([
+                SelectedEntry {
+                    worktree_id,
+                    entry_id: entry_a_id,
+                },
+                SelectedEntry {
+                    worktree_id,
+                    entry_id: entry_b_id,
+                },
+            ]),
+        };
+
+        panel.drag_onto(&drag, root_entry_id, false, window, cx);
+    });
+    cx.executor().run_until_parked();
+
+    let files = fs.files();
+    assert!(files.contains(&PathBuf::from(path!("/root/shared.txt"))));
+
+    let remaining_sources = [
+        PathBuf::from(path!("/root/dir_a/shared.txt")),
+        PathBuf::from(path!("/root/dir_b/shared.txt")),
+    ]
+    .into_iter()
+    .filter(|path| files.contains(path))
+    .count();
+
+    assert_eq!(
+        remaining_sources, 1,
+        "one conflicting source file should remain in place"
+    );
+}
+
 #[gpui::test]
 async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -8586,6 +8670,55 @@ async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_reveal_in_file_manager_path_falls_back_to_worktree_root(
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "file.txt": "content",
+            "dir": {},
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    select_path(&panel, "root/file.txt", cx);
+    let selected_reveal_path = panel
+        .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
+        .expect("selected entry should produce a reveal path");
+    assert!(
+        selected_reveal_path.ends_with(Path::new("file.txt")),
+        "Expected selected file path, got {:?}",
+        selected_reveal_path
+    );
+
+    panel.update(cx, |panel, _| {
+        panel.selection = None;
+        panel.marked_entries.clear();
+    });
+    let fallback_reveal_path = panel
+        .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
+        .expect("project root should be used when selection is empty");
+    assert!(
+        fallback_reveal_path.ends_with(Path::new("root")),
+        "Expected worktree root path, got {:?}",
+        fallback_reveal_path
+    );
+}
+
 #[gpui::test]
 async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
     init_test(cx);

crates/proto/Cargo.toml 🔗

@@ -7,7 +7,7 @@ publish.workspace = true
 license = "GPL-3.0-or-later"
 
 [features]
-test-support = ["collections/test-support"]
+test-support = []
 
 [lints]
 workspace = true
@@ -25,5 +25,3 @@ serde.workspace = true
 prost-build.workspace = true
 
 [dev-dependencies]
-collections = { workspace = true, features = ["test-support"] }
-typed-path = "0.11"

crates/proto/proto/ai.proto 🔗

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

crates/proto/proto/git.proto 🔗

@@ -126,6 +126,7 @@ message UpdateRepository {
   optional string remote_upstream_url = 14;
   optional string remote_origin_url = 15;
   optional string original_repo_abs_path = 16;
+  repeated Worktree linked_worktrees = 17;
 }
 
 message RemoveRepository {
@@ -583,6 +584,20 @@ message GitCreateWorktree {
   optional string commit = 5;
 }
 
+message GitRemoveWorktree {
+  uint64 project_id = 1;
+  uint64 repository_id = 2;
+  string path = 3;
+  bool force = 4;
+}
+
+message GitRenameWorktree {
+  uint64 project_id = 1;
+  uint64 repository_id = 2;
+  string old_path = 3;
+  string new_path = 4;
+}
+
 message RunGitHook {
   enum GitHook {
     PRE_COMMIT = 0;

crates/proto/proto/zed.proto 🔗

@@ -474,7 +474,9 @@ message Envelope {
 
     SpawnKernel spawn_kernel = 426;
     SpawnKernelResponse spawn_kernel_response = 427;
-    KillKernel kill_kernel = 428; // current max
+    KillKernel kill_kernel = 428;
+    GitRemoveWorktree git_remove_worktree = 431;
+    GitRenameWorktree git_rename_worktree = 432; // current max
   }
 
   reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -354,6 +354,8 @@ messages!(
     (GitGetWorktrees, Background),
     (GitWorktreesResponse, Background),
     (GitCreateWorktree, Background),
+    (GitRemoveWorktree, Background),
+    (GitRenameWorktree, Background),
     (ShareAgentThread, Foreground),
     (GetSharedAgentThread, Foreground),
     (GetSharedAgentThreadResponse, Foreground),
@@ -557,6 +559,8 @@ request_messages!(
     (RemoteStarted, Ack),
     (GitGetWorktrees, GitWorktreesResponse),
     (GitCreateWorktree, Ack),
+    (GitRemoveWorktree, Ack),
+    (GitRenameWorktree, Ack),
     (TrustWorktrees, Ack),
     (RestrictWorktrees, Ack),
     (FindSearchCandidatesChunk, Ack),
@@ -747,6 +751,8 @@ entity_messages!(
     NewExternalAgentVersionAvailable,
     GitGetWorktrees,
     GitCreateWorktree,
+    GitRemoveWorktree,
+    GitRenameWorktree,
     TrustWorktrees,
     RestrictWorktrees,
     FindSearchCandidatesChunk,

crates/recent_projects/Cargo.toml 🔗

@@ -59,7 +59,6 @@ indoc.workspace = true
 windows-registry = "0.6.0"
 
 [dev-dependencies]
-dap.workspace = true
 editor = { workspace = true, features = ["test-support"] }
 extension.workspace = true
 fs.workspace = true

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -2,11 +2,7 @@ use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, Focusable, Rende
 use project::project_settings::ProjectSettings;
 use remote::RemoteConnectionOptions;
 use settings::Settings;
-use ui::{
-    Button, ButtonCommon, ButtonStyle, Clickable, Context, ElevationIndex, FluentBuilder, Headline,
-    HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal,
-    ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems,
-};
+use ui::{ElevationIndex, Modal, ModalFooter, ModalHeader, Section, prelude::*};
 use workspace::{
     ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr,
 };
@@ -207,8 +203,7 @@ impl Render for DisconnectedOverlay {
                                         Button::new("reconnect", "Reconnect")
                                             .style(ButtonStyle::Filled)
                                             .layer(ElevationIndex::ModalSurface)
-                                            .icon(IconName::ArrowCircle)
-                                            .icon_position(IconPosition::Start)
+                                            .start_icon(Icon::new(IconName::ArrowCircle))
                                             .on_click(cx.listener(Self::handle_reconnect)),
                                     )
                                 }),

crates/recent_projects/src/recent_projects.rs 🔗

@@ -935,7 +935,14 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 }
                                 return;
                             } else {
-                                workspace.open_workspace_for_paths(false, paths, window, cx)
+                                workspace
+                                    .open_workspace_for_paths(false, paths, window, cx)
+                                    .detach_and_prompt_err(
+                                        "Failed to open project",
+                                        window,
+                                        cx,
+                                        |_, _, _| None,
+                                    );
                             }
                         }
                         SerializedWorkspaceLocation::Remote(mut connection) => {
@@ -964,14 +971,14 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 )
                                 .await
                             })
+                            .detach_and_prompt_err(
+                                "Failed to open project",
+                                window,
+                                cx,
+                                |_, _, _| None,
+                            );
                         }
                     }
-                    .detach_and_prompt_err(
-                        "Failed to open project",
-                        window,
-                        cx,
-                        |_, _, _| None,
-                    );
                 });
                 cx.emit(DismissEvent);
             }
@@ -1241,8 +1248,8 @@ impl PickerDelegate for RecentProjectsDelegate {
         let focus_handle = self.focus_handle.clone();
         let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
         let open_folder_section = matches!(
-            self.filtered_entries.get(self.selected_index)?,
-            ProjectPickerEntry::OpenFolder { .. }
+            self.filtered_entries.get(self.selected_index),
+            Some(ProjectPickerEntry::OpenFolder { .. })
         );
 
         if popover_style {

crates/recent_projects/src/remote_servers.rs 🔗

@@ -390,7 +390,7 @@ impl ProjectPicker {
     ) -> Entity<Self> {
         let (tx, rx) = oneshot::channel();
         let lister = project::DirectoryLister::Project(project.clone());
-        let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx);
+        let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx).show_hidden();
 
         let picker = cx.new(|cx| {
             let picker = Picker::uniform_list(delegate, window, cx)
@@ -1656,7 +1656,9 @@ impl RemoteServerProjects {
 
     fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context<Self>) {
         self.update_settings_file(cx, move |setting, _| {
-            if let Some(connections) = setting.ssh_connections.as_mut() {
+            if let Some(connections) = setting.ssh_connections.as_mut()
+                && connections.get(server.0).is_some()
+            {
                 connections.remove(server.0);
             }
         });
@@ -2115,8 +2117,10 @@ impl RemoteServerProjects {
                                     .child(
                                         Button::new("learn-more", "Learn More")
                                             .label_size(LabelSize::Small)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::XSmall)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::XSmall),
+                                            )
                                             .on_click(|_, _, cx| {
                                                 cx.open_url(
                                                     "https://zed.dev/docs/remote-development",

crates/remote_server/Cargo.toml 🔗

@@ -89,9 +89,7 @@ action_log.workspace = true
 agent = { workspace = true, features = ["test-support"] }
 client = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
-dap = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
-workspace = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
@@ -103,7 +101,6 @@ remote = { workspace = true, features = ["test-support"] }
 theme = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, features = ["test-support"] }
 lsp = { workspace = true, features = ["test-support"] }
-prompt_store.workspace = true
 unindent.workspace = true
 serde_json.workspace = true
 zlog.workspace = true

crates/repl/Cargo.toml 🔗

@@ -62,7 +62,6 @@ zed_actions.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
 indoc.workspace = true

crates/repl/src/components/kernel_options.rs 🔗

@@ -27,6 +27,7 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<Kern
 
     let mut python_envs = Vec::new();
     let mut jupyter_kernels = Vec::new();
+    let mut wsl_kernels = Vec::new();
     let mut remote_kernels = Vec::new();
 
     for spec in store.kernel_specifications_for_worktree(worktree_id) {
@@ -59,14 +60,18 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<Kern
                     is_recommended,
                 });
             }
-            KernelSpecification::JupyterServer(_)
-            | KernelSpecification::SshRemote(_)
-            | KernelSpecification::WslRemote(_) => {
+            KernelSpecification::JupyterServer(_) | KernelSpecification::SshRemote(_) => {
                 remote_kernels.push(KernelPickerEntry::Kernel {
                     spec: spec.clone(),
                     is_recommended,
                 });
             }
+            KernelSpecification::WslRemote(_) => {
+                wsl_kernels.push(KernelPickerEntry::Kernel {
+                    spec: spec.clone(),
+                    is_recommended,
+                });
+            }
         }
     }
 
@@ -105,6 +110,12 @@ fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<Kern
         entries.extend(jupyter_kernels);
     }
 
+    // WSL Kernels section
+    if !wsl_kernels.is_empty() {
+        entries.push(KernelPickerEntry::SectionHeader("WSL Kernels".into()));
+        entries.extend(wsl_kernels);
+    }
+
     // Remote section
     if !remote_kernels.is_empty() {
         entries.push(KernelPickerEntry::SectionHeader("Remote Servers".into()));
@@ -325,10 +336,10 @@ impl PickerDelegate for KernelPickerDelegate {
 
                 let subtitle = match spec {
                     KernelSpecification::Jupyter(_) => None,
+                    KernelSpecification::WslRemote(_) => Some(spec.path().to_string()),
                     KernelSpecification::PythonEnv(_)
                     | KernelSpecification::JupyterServer(_)
-                    | KernelSpecification::SshRemote(_)
-                    | KernelSpecification::WslRemote(_) => {
+                    | KernelSpecification::SshRemote(_) => {
                         let env_kind = spec.environment_kind_label();
                         let path = spec.path();
                         match env_kind {
@@ -420,10 +431,11 @@ impl PickerDelegate for KernelPickerDelegate {
                 .gap_4()
                 .child(
                     Button::new("kernel-docs", "Kernel Docs")
-                        .icon(IconName::ArrowUpRight)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
-                        .icon_position(IconPosition::End)
+                        .end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .on_click(move |_, _, cx| cx.open_url(KERNEL_DOCS_URL)),
                 )
                 .into_any(),
@@ -437,7 +449,9 @@ where
     TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
 {
     fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let store = ReplStore::global(cx).read(cx);
+        let store = ReplStore::global(cx);
+        store.update(cx, |store, cx| store.ensure_kernelspecs(cx));
+        let store = store.read(cx);
 
         let all_entries = build_grouped_entries(store, self.worktree_id);
         let selected_kernelspec = store.active_kernelspec(self.worktree_id, None, cx);

crates/repl/src/kernels/mod.rs 🔗

@@ -9,6 +9,7 @@ pub use native_kernel::*;
 
 mod remote_kernels;
 use project::{Project, ProjectPath, Toolchains, WorktreeId};
+use remote::RemoteConnectionOptions;
 pub use remote_kernels::*;
 
 mod ssh_kernel;
@@ -238,7 +239,7 @@ impl KernelSpecification {
             Self::PythonEnv(spec) => spec.name.clone().into(),
             Self::JupyterServer(spec) => spec.name.clone().into(),
             Self::SshRemote(spec) => spec.name.clone().into(),
-            Self::WslRemote(spec) => spec.name.clone().into(),
+            Self::WslRemote(spec) => spec.kernelspec.display_name.clone().into(),
         }
     }
 
@@ -262,7 +263,7 @@ impl KernelSpecification {
             Self::PythonEnv(spec) => spec.path.to_string_lossy().into_owned(),
             Self::JupyterServer(spec) => spec.url.to_string(),
             Self::SshRemote(spec) => spec.path.to_string(),
-            Self::WslRemote(_) => "WSL".to_string(),
+            Self::WslRemote(spec) => spec.distro.clone(),
         })
     }
 
@@ -348,7 +349,16 @@ pub fn python_env_kernel_specifications(
 ) -> impl Future<Output = Result<Vec<KernelSpecification>>> + use<> {
     let python_language = LanguageName::new_static("Python");
     let is_remote = project.read(cx).is_remote();
-    log::info!("python_env_kernel_specifications: is_remote: {}", is_remote);
+    let wsl_distro = project
+        .read(cx)
+        .remote_connection_options(cx)
+        .and_then(|opts| {
+            if let RemoteConnectionOptions::Wsl(wsl) = opts {
+                Some(wsl.distro_name)
+            } else {
+                None
+            }
+        });
 
     let toolchains = project.read(cx).available_toolchains(
         ProjectPath {
@@ -383,6 +393,7 @@ pub fn python_env_kernel_specifications(
             .flatten()
             .chain(toolchains.toolchains)
             .map(|toolchain| {
+                let wsl_distro = wsl_distro.clone();
                 background_executor.spawn(async move {
                     // For remote projects, we assume python is available assuming toolchain is reported.
                     // We can skip the `ipykernel` check or run it remotely.
@@ -390,10 +401,6 @@ pub fn python_env_kernel_specifications(
                     // `new_smol_command` runs locally. We need to run remotely if `is_remote`.
 
                     if is_remote {
-                        log::info!(
-                            "python_env_kernel_specifications: returning SshRemote for toolchain {}",
-                            toolchain.name
-                        );
                         let default_kernelspec = JupyterKernelspec {
                             argv: vec![
                                 toolchain.path.to_string(),
@@ -409,6 +416,22 @@ pub fn python_env_kernel_specifications(
                             env: None,
                         };
 
+                        if let Some(distro) = wsl_distro {
+                            log::debug!(
+                                "python_env_kernel_specifications: returning WslRemote for toolchain {}",
+                                toolchain.name
+                            );
+                            return Some(KernelSpecification::WslRemote(WslKernelSpecification {
+                                name: toolchain.name.to_string(),
+                                kernelspec: default_kernelspec,
+                                distro,
+                            }));
+                        }
+
+                        log::debug!(
+                            "python_env_kernel_specifications: returning SshRemote for toolchain {}",
+                            toolchain.name
+                        );
                         return Some(KernelSpecification::SshRemote(
                             SshRemoteKernelSpecification {
                                 name: format!("Remote {}", toolchain.name),

crates/repl/src/kernels/native_kernel.rs 🔗

@@ -19,7 +19,7 @@ use std::{
     path::PathBuf,
     sync::Arc,
 };
-use util::command::Command;
+
 use uuid::Uuid;
 
 use super::{KernelSession, RunningKernel, start_kernel_tasks};
@@ -41,7 +41,7 @@ impl Eq for LocalKernelSpecification {}
 
 impl LocalKernelSpecification {
     #[must_use]
-    fn command(&self, connection_path: &PathBuf) -> Result<Command> {
+    fn command(&self, connection_path: &PathBuf) -> Result<std::process::Command> {
         let argv = &self.kernelspec.argv;
 
         anyhow::ensure!(!argv.is_empty(), "Empty argv in kernelspec {}", self.name);
@@ -52,7 +52,7 @@ impl LocalKernelSpecification {
             self.name
         );
 
-        let mut cmd = util::command::new_command(&argv[0]);
+        let mut cmd = util::command::new_std_command(&argv[0]);
 
         for arg in &argv[1..] {
             if arg == "{connection_file}" {
@@ -91,7 +91,7 @@ async fn peek_ports(ip: IpAddr) -> Result<[u16; 5]> {
 }
 
 pub struct NativeRunningKernel {
-    pub process: util::command::Child,
+    pub process: util::process::Child,
     connection_path: PathBuf,
     _process_status_task: Option<Task<()>>,
     pub working_directory: PathBuf,
@@ -104,7 +104,7 @@ pub struct NativeRunningKernel {
 impl Debug for NativeRunningKernel {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("RunningKernel")
-            .field("process", &self.process)
+            .field("process", &*self.process)
             .finish()
     }
 }
@@ -146,15 +146,14 @@ impl NativeRunningKernel {
             fs.atomic_write(connection_path.clone(), content).await?;
 
             let mut cmd = kernel_specification.command(&connection_path)?;
-
-            let mut process = cmd
-                .current_dir(&working_directory)
-                .stdout(util::command::Stdio::piped())
-                .stderr(util::command::Stdio::piped())
-                .stdin(util::command::Stdio::piped())
-                .kill_on_drop(true)
-                .spawn()
-                .context("failed to start the kernel process")?;
+            cmd.current_dir(&working_directory);
+
+            let mut process = util::process::Child::spawn(
+                cmd,
+                std::process::Stdio::piped(),
+                std::process::Stdio::piped(),
+                std::process::Stdio::piped(),
+            )?;
 
             let session_id = Uuid::new_v4().to_string();
 

crates/repl/src/kernels/wsl_kernel.rs 🔗

@@ -274,7 +274,23 @@ impl WslRunningKernel {
                     cd_command, set_env_command, arg_string, arg_string, arg_string, arg_string
                 )
             } else {
-                quote_posix_shell_arguments(&kernel_args)?
+                let args_string = quote_posix_shell_arguments(&resolved_argv)?;
+
+                let cd_command = if let Some(wd) = wsl_working_directory.as_ref() {
+                    let quoted_wd = shlex::try_quote(wd)
+                        .map(|quoted| quoted.into_owned())?;
+                    format!("cd {quoted_wd} && ")
+                } else {
+                    String::new()
+                };
+
+                let env_prefix_inline = if !env_assignments.is_empty() {
+                    format!("env {} ", env_assignments.join(" "))
+                } else {
+                    String::new()
+                };
+
+                format!("{cd_command}exec {env_prefix_inline}{args_string}")
             };
 
             cmd.arg("bash")
@@ -578,8 +594,20 @@ pub async fn wsl_kernel_specifications(
                                 })
                             })
                             .collect::<Vec<_>>();
+                    } else if let Err(e) =
+                        serde_json::from_str::<LocalKernelSpecsResponse>(&json_str)
+                    {
+                        log::error!(
+                            "wsl_kernel_specifications parse error: {} \nJSON: {}",
+                            e,
+                            json_str
+                        );
                     }
+                } else {
+                    log::error!("wsl_kernel_specifications command failed");
                 }
+            } else if let Err(e) = output {
+                log::error!("wsl_kernel_specifications command execution failed: {}", e);
             }
 
             Vec::new()

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -1117,10 +1117,11 @@ impl NotebookEditor {
                     worktree_id,
                     Button::new("kernel-selector", kernel_name.clone())
                         .label_size(LabelSize::Small)
-                        .icon(status_icon)
-                        .icon_size(IconSize::Small)
-                        .icon_color(status_color)
-                        .icon_position(IconPosition::Start),
+                        .start_icon(
+                            Icon::new(status_icon)
+                                .size(IconSize::Small)
+                                .color(status_color),
+                        ),
                     Tooltip::text(format!(
                         "Kernel: {} ({}). Click to change.",
                         kernel_name,

crates/repl/src/repl.rs 🔗

@@ -46,11 +46,9 @@ fn zed_dispatcher(cx: &mut App) -> impl Dispatcher {
     impl Dispatcher for ZedDispatcher {
         #[track_caller]
         fn dispatch(&self, runnable: Runnable) {
-            use std::sync::{Arc, atomic::AtomicBool};
             let location = core::panic::Location::caller();
-            let closed = Arc::new(AtomicBool::new(false));
             let (wrapper, task) = async_task::Builder::new()
-                .metadata(RunnableMeta { location, closed })
+                .metadata(RunnableMeta { location })
                 .spawn(|_| async move { runnable.run() }, {
                     let dispatcher = self.dispatcher.clone();
                     move |r| dispatcher.dispatch(r, Priority::default())
@@ -61,11 +59,9 @@ fn zed_dispatcher(cx: &mut App) -> impl Dispatcher {
 
         #[track_caller]
         fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
-            use std::sync::{Arc, atomic::AtomicBool};
             let location = core::panic::Location::caller();
-            let closed = Arc::new(AtomicBool::new(false));
             let (wrapper, task) = async_task::Builder::new()
-                .metadata(RunnableMeta { location, closed })
+                .metadata(RunnableMeta { location })
                 .spawn(|_| async move { runnable.run() }, {
                     let dispatcher = self.dispatcher.clone();
                     move |r| dispatcher.dispatch_after(duration, r)

crates/repl/src/repl_editor.rs 🔗

@@ -191,6 +191,7 @@ pub fn run(
     if !store.read(cx).is_enabled() {
         return Ok(());
     }
+    store.update(cx, |store, cx| store.ensure_kernelspecs(cx));
 
     let editor = editor.upgrade().context("editor was dropped")?;
     let selected_range = editor

crates/repl/src/repl_sessions_ui.rs 🔗

@@ -204,7 +204,8 @@ impl Render for ReplSessionsPage {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let store = ReplStore::global(cx);
 
-        let (kernel_specifications, sessions) = store.update(cx, |store, _cx| {
+        let (kernel_specifications, sessions) = store.update(cx, |store, cx| {
+            store.ensure_kernelspecs(cx);
             (
                 store
                     .pure_jupyter_kernel_specifications()

crates/repl/src/repl_store.rs 🔗

@@ -8,6 +8,7 @@ use gpui::{App, Context, Entity, EntityId, Global, SharedString, Subscription, T
 use jupyter_websocket_client::RemoteServer;
 use language::{Language, LanguageName};
 use project::{Fs, Project, ProjectPath, WorktreeId};
+use remote::RemoteConnectionOptions;
 use settings::{Settings, SettingsStore};
 use util::rel_path::RelPath;
 
@@ -26,6 +27,7 @@ pub struct ReplStore {
     enabled: bool,
     sessions: HashMap<EntityId, Entity<Session>>,
     kernel_specifications: Vec<KernelSpecification>,
+    kernelspecs_initialized: bool,
     selected_kernel_for_worktree: HashMap<WorktreeId, KernelSpecification>,
     kernel_specifications_for_worktree: HashMap<WorktreeId, Vec<KernelSpecification>>,
     active_python_toolchain_for_worktree: HashMap<WorktreeId, SharedString>,
@@ -38,12 +40,6 @@ impl ReplStore {
 
     pub(crate) fn init(fs: Arc<dyn Fs>, cx: &mut App) {
         let store = cx.new(move |cx| Self::new(fs, cx));
-
-        #[cfg(not(feature = "test-support"))]
-        store
-            .update(cx, |store, cx| store.refresh_kernelspecs(cx))
-            .detach_and_log_err(cx);
-
         cx.set_global(GlobalReplStore(store))
     }
 
@@ -64,6 +60,7 @@ impl ReplStore {
             enabled: JupyterSettings::enabled(cx),
             sessions: HashMap::default(),
             kernel_specifications: Vec::new(),
+            kernelspecs_initialized: false,
             _subscriptions: subscriptions,
             kernel_specifications_for_worktree: HashMap::default(),
             selected_kernel_for_worktree: HashMap::default(),
@@ -144,6 +141,14 @@ impl ReplStore {
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
         let is_remote = project.read(cx).is_remote();
+        // WSL does require access to global kernel specs, so we only exclude remote worktrees that aren't WSL.
+        // TODO: a better way to handle WSL vs SSH/remote projects,
+        let is_wsl_remote = project
+            .read(cx)
+            .remote_connection_options(cx)
+            .map_or(false, |opts| {
+                matches!(opts, RemoteConnectionOptions::Wsl(_))
+            });
         let kernel_specifications = python_env_kernel_specifications(project, worktree_id, cx);
         let active_toolchain = project.read(cx).active_toolchain(
             ProjectPath {
@@ -168,7 +173,7 @@ impl ReplStore {
                     this.active_python_toolchain_for_worktree
                         .insert(worktree_id, path);
                 }
-                if is_remote {
+                if is_remote && !is_wsl_remote {
                     this.remote_worktrees.insert(worktree_id);
                 } else {
                     this.remote_worktrees.remove(&worktree_id);
@@ -207,10 +212,17 @@ impl ReplStore {
         }
     }
 
+    pub fn ensure_kernelspecs(&mut self, cx: &mut Context<Self>) {
+        if self.kernelspecs_initialized {
+            return;
+        }
+        self.kernelspecs_initialized = true;
+        self.refresh_kernelspecs(cx).detach_and_log_err(cx);
+    }
+
     pub fn refresh_kernelspecs(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
         let local_kernel_specifications = local_kernel_specifications(self.fs.clone());
         let wsl_kernel_specifications = wsl_kernel_specifications(cx.background_executor().clone());
-
         let remote_kernel_specifications = self.get_remote_kernel_specifications(cx);
 
         let all_specs = cx.background_spawn(async move {

crates/rich_text/Cargo.toml 🔗

@@ -1,29 +0,0 @@
-[package]
-name = "rich_text"
-version = "0.1.0"
-edition.workspace = true
-publish.workspace = true
-license = "GPL-3.0-or-later"
-
-[lints]
-workspace = true
-
-[lib]
-path = "src/rich_text.rs"
-doctest = false
-
-[features]
-test-support = [
-    "gpui/test-support",
-    "util/test-support",
-]
-
-[dependencies]
-futures.workspace = true
-gpui.workspace = true
-language.workspace = true
-linkify.workspace = true
-pulldown-cmark.workspace = true
-theme.workspace = true
-ui.workspace = true
-util.workspace = true

crates/rich_text/src/rich_text.rs 🔗

@@ -1,418 +0,0 @@
-use futures::FutureExt;
-use gpui::{
-    AnyElement, AnyView, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
-    IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
-};
-use language::{HighlightId, Language, LanguageRegistry};
-use std::{ops::Range, sync::Arc};
-use theme::ActiveTheme;
-use ui::LinkPreview;
-use util::RangeExt;
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum Highlight {
-    Code,
-    Id(HighlightId),
-    InlineCode(bool),
-    Highlight(HighlightStyle),
-    Mention,
-    SelfMention,
-}
-
-impl From<HighlightStyle> for Highlight {
-    fn from(style: HighlightStyle) -> Self {
-        Self::Highlight(style)
-    }
-}
-
-impl From<HighlightId> for Highlight {
-    fn from(style: HighlightId) -> Self {
-        Self::Id(style)
-    }
-}
-
-#[derive(Clone, Default)]
-pub struct RichText {
-    pub text: SharedString,
-    pub highlights: Vec<(Range<usize>, Highlight)>,
-    pub link_ranges: Vec<Range<usize>>,
-    pub link_urls: Arc<[String]>,
-
-    pub custom_ranges: Vec<Range<usize>>,
-    custom_ranges_tooltip_fn:
-        Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>,
-}
-
-/// Allows one to specify extra links to the rendered markdown, which can be used
-/// for e.g. mentions.
-#[derive(Debug)]
-pub struct Mention {
-    pub range: Range<usize>,
-    pub is_self_mention: bool,
-}
-
-impl RichText {
-    pub fn new(
-        block: String,
-        mentions: &[Mention],
-        language_registry: &Arc<LanguageRegistry>,
-    ) -> Self {
-        let mut text = String::new();
-        let mut highlights = Vec::new();
-        let mut link_ranges = Vec::new();
-        let mut link_urls = Vec::new();
-        render_markdown_mut(
-            &block,
-            mentions,
-            language_registry,
-            None,
-            &mut text,
-            &mut highlights,
-            &mut link_ranges,
-            &mut link_urls,
-        );
-        text.truncate(text.trim_end().len());
-
-        RichText {
-            text: SharedString::from(text),
-            link_urls: link_urls.into(),
-            link_ranges,
-            highlights,
-            custom_ranges: Vec::new(),
-            custom_ranges_tooltip_fn: None,
-        }
-    }
-
-    pub fn set_tooltip_builder_for_custom_ranges(
-        &mut self,
-        f: impl Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
-    ) {
-        self.custom_ranges_tooltip_fn = Some(Arc::new(f));
-    }
-
-    pub fn element(&self, id: ElementId, window: &mut Window, cx: &mut App) -> AnyElement {
-        let theme = cx.theme();
-        let code_background = theme.colors().surface_background;
-
-        InteractiveText::new(
-            id,
-            StyledText::new(self.text.clone()).with_default_highlights(
-                &window.text_style(),
-                self.highlights.iter().map(|(range, highlight)| {
-                    (
-                        range.clone(),
-                        match highlight {
-                            Highlight::Code => HighlightStyle {
-                                background_color: Some(code_background),
-                                ..Default::default()
-                            },
-                            Highlight::Id(id) => HighlightStyle {
-                                background_color: Some(code_background),
-                                ..id.style(theme.syntax()).unwrap_or_default()
-                            },
-                            Highlight::InlineCode(link) => {
-                                if *link {
-                                    HighlightStyle {
-                                        background_color: Some(code_background),
-                                        underline: Some(UnderlineStyle {
-                                            thickness: 1.0.into(),
-                                            ..Default::default()
-                                        }),
-                                        ..Default::default()
-                                    }
-                                } else {
-                                    HighlightStyle {
-                                        background_color: Some(code_background),
-                                        ..Default::default()
-                                    }
-                                }
-                            }
-                            Highlight::Highlight(highlight) => *highlight,
-                            Highlight::Mention => HighlightStyle {
-                                font_weight: Some(FontWeight::BOLD),
-                                ..Default::default()
-                            },
-                            Highlight::SelfMention => HighlightStyle {
-                                font_weight: Some(FontWeight::BOLD),
-                                ..Default::default()
-                            },
-                        },
-                    )
-                }),
-            ),
-        )
-        .on_click(self.link_ranges.clone(), {
-            let link_urls = self.link_urls.clone();
-            move |ix, _, cx| {
-                let url = &link_urls[ix];
-                if url.starts_with("http") {
-                    cx.open_url(url);
-                }
-            }
-        })
-        .tooltip({
-            let link_ranges = self.link_ranges.clone();
-            let link_urls = self.link_urls.clone();
-            let custom_tooltip_ranges = self.custom_ranges.clone();
-            let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone();
-            move |idx, window, cx| {
-                for (ix, range) in link_ranges.iter().enumerate() {
-                    if range.contains(&idx) {
-                        return Some(LinkPreview::new(&link_urls[ix], cx));
-                    }
-                }
-                for range in &custom_tooltip_ranges {
-                    if range.contains(&idx)
-                        && let Some(f) = &custom_tooltip_fn
-                    {
-                        return f(idx, range.clone(), window, cx);
-                    }
-                }
-                None
-            }
-        })
-        .into_any_element()
-    }
-}
-
-pub fn render_markdown_mut(
-    block: &str,
-    mut mentions: &[Mention],
-    language_registry: &Arc<LanguageRegistry>,
-    language: Option<&Arc<Language>>,
-    text: &mut String,
-    highlights: &mut Vec<(Range<usize>, Highlight)>,
-    link_ranges: &mut Vec<Range<usize>>,
-    link_urls: &mut Vec<String>,
-) {
-    use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
-
-    let mut bold_depth = 0;
-    let mut italic_depth = 0;
-    let mut strikethrough_depth = 0;
-    let mut link_url = None;
-    let mut current_language = None;
-    let mut list_stack = Vec::new();
-
-    let mut options = Options::all();
-    options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
-
-    for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
-        let prev_len = text.len();
-        match event {
-            Event::Text(t) => {
-                if let Some(language) = &current_language {
-                    render_code(text, highlights, t.as_ref(), language);
-                } else {
-                    while let Some(mention) = mentions.first() {
-                        if !source_range.contains_inclusive(&mention.range) {
-                            break;
-                        }
-                        mentions = &mentions[1..];
-                        let range = (prev_len + mention.range.start - source_range.start)
-                            ..(prev_len + mention.range.end - source_range.start);
-                        highlights.push((
-                            range.clone(),
-                            if mention.is_self_mention {
-                                Highlight::SelfMention
-                            } else {
-                                Highlight::Mention
-                            },
-                        ));
-                    }
-
-                    text.push_str(t.as_ref());
-                    let mut style = HighlightStyle::default();
-                    if bold_depth > 0 {
-                        style.font_weight = Some(FontWeight::BOLD);
-                    }
-                    if italic_depth > 0 {
-                        style.font_style = Some(FontStyle::Italic);
-                    }
-                    if strikethrough_depth > 0 {
-                        style.strikethrough = Some(StrikethroughStyle {
-                            thickness: 1.0.into(),
-                            ..Default::default()
-                        });
-                    }
-                    let last_run_len = if let Some(link_url) = link_url.clone() {
-                        link_ranges.push(prev_len..text.len());
-                        link_urls.push(link_url);
-                        style.underline = Some(UnderlineStyle {
-                            thickness: 1.0.into(),
-                            ..Default::default()
-                        });
-                        prev_len
-                    } else {
-                        // Manually scan for links
-                        let mut finder = linkify::LinkFinder::new();
-                        finder.kinds(&[linkify::LinkKind::Url]);
-                        let mut last_link_len = prev_len;
-                        for link in finder.links(&t) {
-                            let start = link.start();
-                            let end = link.end();
-                            let range = (prev_len + start)..(prev_len + end);
-                            link_ranges.push(range.clone());
-                            link_urls.push(link.as_str().to_string());
-
-                            // If there is a style before we match a link, we have to add this to the highlighted ranges
-                            if style != HighlightStyle::default() && last_link_len < link.start() {
-                                highlights.push((
-                                    last_link_len..link.start(),
-                                    Highlight::Highlight(style),
-                                ));
-                            }
-
-                            highlights.push((
-                                range,
-                                Highlight::Highlight(HighlightStyle {
-                                    underline: Some(UnderlineStyle {
-                                        thickness: 1.0.into(),
-                                        ..Default::default()
-                                    }),
-                                    ..style
-                                }),
-                            ));
-
-                            last_link_len = end;
-                        }
-                        last_link_len
-                    };
-
-                    if style != HighlightStyle::default() && last_run_len < text.len() {
-                        let mut new_highlight = true;
-                        if let Some((last_range, last_style)) = highlights.last_mut()
-                            && last_range.end == last_run_len
-                            && last_style == &Highlight::Highlight(style)
-                        {
-                            last_range.end = text.len();
-                            new_highlight = false;
-                        }
-                        if new_highlight {
-                            highlights
-                                .push((last_run_len..text.len(), Highlight::Highlight(style)));
-                        }
-                    }
-                }
-            }
-            Event::Code(t) => {
-                text.push_str(t.as_ref());
-                let is_link = link_url.is_some();
-
-                if let Some(link_url) = link_url.clone() {
-                    link_ranges.push(prev_len..text.len());
-                    link_urls.push(link_url);
-                }
-
-                highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
-            }
-            Event::Start(tag) => match tag {
-                Tag::Paragraph => new_paragraph(text, &mut list_stack),
-                Tag::Heading { .. } => {
-                    new_paragraph(text, &mut list_stack);
-                    bold_depth += 1;
-                }
-                Tag::CodeBlock(kind) => {
-                    new_paragraph(text, &mut list_stack);
-                    current_language = if let CodeBlockKind::Fenced(language) = kind {
-                        language_registry
-                            .language_for_name(language.as_ref())
-                            .now_or_never()
-                            .and_then(Result::ok)
-                    } else {
-                        language.cloned()
-                    }
-                }
-                Tag::Emphasis => italic_depth += 1,
-                Tag::Strong => bold_depth += 1,
-                Tag::Strikethrough => strikethrough_depth += 1,
-                Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()),
-                Tag::List(number) => {
-                    list_stack.push((number, false));
-                }
-                Tag::Item => {
-                    let len = list_stack.len();
-                    if let Some((list_number, has_content)) = list_stack.last_mut() {
-                        *has_content = false;
-                        if !text.is_empty() && !text.ends_with('\n') {
-                            text.push('\n');
-                        }
-                        for _ in 0..len - 1 {
-                            text.push_str("  ");
-                        }
-                        if let Some(number) = list_number {
-                            text.push_str(&format!("{}. ", number));
-                            *number += 1;
-                            *has_content = false;
-                        } else {
-                            text.push_str("- ");
-                        }
-                    }
-                }
-                _ => {}
-            },
-            Event::End(tag) => match tag {
-                TagEnd::Heading(_) => bold_depth -= 1,
-                TagEnd::CodeBlock => current_language = None,
-                TagEnd::Emphasis => italic_depth -= 1,
-                TagEnd::Strong => bold_depth -= 1,
-                TagEnd::Strikethrough => strikethrough_depth -= 1,
-                TagEnd::Link => link_url = None,
-                TagEnd::List(_) => drop(list_stack.pop()),
-                _ => {}
-            },
-            Event::HardBreak => text.push('\n'),
-            Event::SoftBreak => text.push('\n'),
-            _ => {}
-        }
-    }
-}
-
-pub fn render_code(
-    text: &mut String,
-    highlights: &mut Vec<(Range<usize>, Highlight)>,
-    content: &str,
-    language: &Arc<Language>,
-) {
-    let prev_len = text.len();
-    text.push_str(content);
-    let mut offset = 0;
-    for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
-        if range.start > offset {
-            highlights.push((prev_len + offset..prev_len + range.start, Highlight::Code));
-        }
-        highlights.push((
-            prev_len + range.start..prev_len + range.end,
-            Highlight::Id(highlight_id),
-        ));
-        offset = range.end;
-    }
-    if offset < content.len() {
-        highlights.push((prev_len + offset..prev_len + content.len(), Highlight::Code));
-    }
-}
-
-pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
-    let mut is_subsequent_paragraph_of_list = false;
-    if let Some((_, has_content)) = list_stack.last_mut() {
-        if *has_content {
-            is_subsequent_paragraph_of_list = true;
-        } else {
-            *has_content = true;
-            return;
-        }
-    }
-
-    if !text.is_empty() {
-        if !text.ends_with('\n') {
-            text.push('\n');
-        }
-        text.push('\n');
-    }
-    for _ in 0..list_stack.len().saturating_sub(1) {
-        text.push_str("  ");
-    }
-    if is_subsequent_paragraph_of_list {
-        text.push_str("  ");
-    }
-}

crates/rope/src/rope.rs 🔗

@@ -693,16 +693,21 @@ impl<'a> Cursor<'a> {
     }
 
     pub fn seek_forward(&mut self, end_offset: usize) {
-        debug_assert!(end_offset >= self.offset);
+        assert!(
+            end_offset >= self.offset,
+            "cannot seek backward from {} to {}",
+            self.offset,
+            end_offset
+        );
 
         self.chunks.seek_forward(&end_offset, Bias::Right);
         self.offset = end_offset;
     }
 
     pub fn slice(&mut self, end_offset: usize) -> Rope {
-        debug_assert!(
+        assert!(
             end_offset >= self.offset,
-            "cannot slice backwards from {} to {}",
+            "cannot slice backward from {} to {}",
             self.offset,
             end_offset
         );
@@ -730,7 +735,12 @@ impl<'a> Cursor<'a> {
     }
 
     pub fn summary<D: TextDimension>(&mut self, end_offset: usize) -> D {
-        debug_assert!(end_offset >= self.offset);
+        assert!(
+            end_offset >= self.offset,
+            "cannot summarize backward from {} to {}",
+            self.offset,
+            end_offset
+        );
 
         let mut summary = D::zero(());
         if let Some(start_chunk) = self.chunks.item() {

crates/rules_library/src/rules_library.rs 🔗

@@ -3,9 +3,9 @@ use collections::{HashMap, HashSet};
 use editor::{CompletionProvider, SelectionEffects};
 use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
 use gpui::{
-    App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel,
-    Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle,
-    WindowOptions, actions, point, size, transparent_black,
+    App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, MouseButton,
+    PromptLevel, Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds,
+    WindowHandle, WindowOptions, actions, point, size, transparent_black,
 };
 use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
 use language_model::{
@@ -133,6 +133,7 @@ pub fn open_rules_library(
                     window_decorations: Some(window_decorations),
                     window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE),
                     kind: gpui::WindowKind::Floating,
+                    is_movable: !cfg!(target_os = "macos"),
                     ..Default::default()
                 },
                 |window, cx| {
@@ -503,11 +504,7 @@ impl RulesLibrary {
         });
 
         Self {
-            title_bar: if !cfg!(target_os = "macos") {
-                Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx)))
-            } else {
-                None
-            },
+            title_bar: Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))),
             store,
             language_registry,
             rule_editors: HashMap::default(),
@@ -1129,40 +1126,55 @@ impl RulesLibrary {
         v_flex()
             .id("rule-list")
             .capture_action(cx.listener(Self::focus_active_rule))
-            .px_1p5()
             .h_full()
             .w_64()
             .overflow_x_hidden()
             .bg(cx.theme().colors().panel_background)
+            .when(!cfg!(target_os = "macos"), |this| this.px_1p5())
             .map(|this| {
                 if cfg!(target_os = "macos") {
-                    this.child(
-                        h_flex()
-                            .p(DynamicSpacing::Base04.rems(cx))
-                            .h_9()
-                            .w_full()
-                            .flex_none()
-                            .justify_end()
-                            .child(
-                                IconButton::new("new-rule", IconName::Plus)
-                                    .tooltip(move |_window, cx| {
-                                        Tooltip::for_action("New Rule", &NewRule, cx)
-                                    })
-                                    .on_click(|_, window, cx| {
-                                        window.dispatch_action(Box::new(NewRule), cx);
-                                    }),
-                            ),
-                    )
+                    let Some(title_bar) = self.title_bar.as_ref() else {
+                        return this;
+                    };
+                    let button_padding = DynamicSpacing::Base08.rems(cx);
+                    let panel_background = cx.theme().colors().panel_background;
+                    title_bar.update(cx, |title_bar, _cx| {
+                        title_bar.set_background_color(Some(panel_background));
+                        title_bar.set_children(Some(
+                            h_flex()
+                                .w_full()
+                                .pr(button_padding)
+                                .justify_end()
+                                .child(
+                                    div()
+                                        .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                                            cx.stop_propagation();
+                                        })
+                                        .child(
+                                            IconButton::new("new-rule", IconName::Plus)
+                                                .tooltip(move |_window, cx| {
+                                                    Tooltip::for_action("New Rule", &NewRule, cx)
+                                                })
+                                                .on_click(|_, window, cx| {
+                                                    window.dispatch_action(Box::new(NewRule), cx);
+                                                }),
+                                        ),
+                                )
+                                .into_any_element(),
+                        ));
+                    });
+                    this.child(title_bar.clone())
                 } else {
                     this.child(
                         h_flex().p_1().w_full().child(
                             Button::new("new-rule", "New Rule")
                                 .full_width()
                                 .style(ButtonStyle::Outlined)
-                                .icon(IconName::Plus)
-                                .icon_size(IconSize::Small)
-                                .icon_position(IconPosition::Start)
-                                .icon_color(Color::Muted)
+                                .start_icon(
+                                    Icon::new(IconName::Plus)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, window, cx| {
                                     window.dispatch_action(Box::new(NewRule), cx);
                                 }),
@@ -1170,7 +1182,12 @@ impl RulesLibrary {
                     )
                 }
             })
-            .child(div().flex_grow().child(self.picker.clone()))
+            .child(
+                div()
+                    .flex_grow()
+                    .when(cfg!(target_os = "macos"), |this| this.px_1p5())
+                    .child(self.picker.clone()),
+            )
     }
 
     fn render_active_rule_editor(
@@ -1417,7 +1434,9 @@ impl Render for RulesLibrary {
                 .overflow_hidden()
                 .font(ui_font)
                 .text_color(theme.colors().text)
-                .children(self.title_bar.clone())
+                .when(!cfg!(target_os = "macos"), |this| {
+                    this.children(self.title_bar.clone())
+                })
                 .bg(theme.colors().background)
                 .child(
                     h_flex()

crates/scheduler/src/executor.rs 🔗

@@ -6,10 +6,7 @@ use std::{
     panic::Location,
     pin::Pin,
     rc::Rc,
-    sync::{
-        Arc,
-        atomic::{AtomicBool, Ordering},
-    },
+    sync::Arc,
     task::{Context, Poll},
     thread::{self, ThreadId},
     time::Duration,
@@ -19,7 +16,6 @@ use std::{
 pub struct ForegroundExecutor {
     session_id: SessionId,
     scheduler: Arc<dyn Scheduler>,
-    closed: Arc<AtomicBool>,
     not_send: PhantomData<Rc<()>>,
 }
 
@@ -28,7 +24,6 @@ impl ForegroundExecutor {
         Self {
             session_id,
             scheduler,
-            closed: Arc::new(AtomicBool::new(false)),
             not_send: PhantomData,
         }
     }
@@ -41,16 +36,6 @@ impl ForegroundExecutor {
         &self.scheduler
     }
 
-    /// Returns the closed flag for this executor.
-    pub fn closed(&self) -> &Arc<AtomicBool> {
-        &self.closed
-    }
-
-    /// Close this executor. Tasks will not run after this is called.
-    pub fn close(&self) {
-        self.closed.store(true, Ordering::SeqCst);
-    }
-
     #[track_caller]
     pub fn spawn<F>(&self, future: F) -> Task<F::Output>
     where
@@ -60,13 +45,12 @@ impl ForegroundExecutor {
         let session_id = self.session_id;
         let scheduler = Arc::clone(&self.scheduler);
         let location = Location::caller();
-        let closed = self.closed.clone();
         let (runnable, task) = spawn_local_with_source_location(
             future,
             move |runnable| {
                 scheduler.schedule_foreground(session_id, runnable);
             },
-            RunnableMeta { location, closed },
+            RunnableMeta { location },
         );
         runnable.schedule();
         Task(TaskState::Spawned(task))
@@ -129,25 +113,11 @@ impl ForegroundExecutor {
 #[derive(Clone)]
 pub struct BackgroundExecutor {
     scheduler: Arc<dyn Scheduler>,
-    closed: Arc<AtomicBool>,
 }
 
 impl BackgroundExecutor {
     pub fn new(scheduler: Arc<dyn Scheduler>) -> Self {
-        Self {
-            scheduler,
-            closed: Arc::new(AtomicBool::new(false)),
-        }
-    }
-
-    /// Returns the closed flag for this executor.
-    pub fn closed(&self) -> &Arc<AtomicBool> {
-        &self.closed
-    }
-
-    /// Close this executor. Tasks will not run after this is called.
-    pub fn close(&self) {
-        self.closed.store(true, Ordering::SeqCst);
+        Self { scheduler }
     }
 
     #[track_caller]
@@ -167,9 +137,8 @@ impl BackgroundExecutor {
     {
         let scheduler = Arc::clone(&self.scheduler);
         let location = Location::caller();
-        let closed = self.closed.clone();
         let (runnable, task) = async_task::Builder::new()
-            .metadata(RunnableMeta { location, closed })
+            .metadata(RunnableMeta { location })
             .spawn(
                 move |_| future,
                 move |runnable| {
@@ -188,20 +157,16 @@ impl BackgroundExecutor {
         F::Output: Send + 'static,
     {
         let location = Location::caller();
-        let closed = self.closed.clone();
         let (tx, rx) = flume::bounded::<async_task::Runnable<RunnableMeta>>(1);
 
         self.scheduler.spawn_realtime(Box::new(move || {
             while let Ok(runnable) = rx.recv() {
-                if runnable.metadata().is_closed() {
-                    continue;
-                }
                 runnable.run();
             }
         }));
 
         let (runnable, task) = async_task::Builder::new()
-            .metadata(RunnableMeta { location, closed })
+            .metadata(RunnableMeta { location })
             .spawn(
                 move |_| future,
                 move |runnable| {

crates/scheduler/src/scheduler.rs 🔗

@@ -14,10 +14,7 @@ use std::{
     future::Future,
     panic::Location,
     pin::Pin,
-    sync::{
-        Arc,
-        atomic::{AtomicBool, Ordering},
-    },
+    sync::Arc,
     task::{Context, Poll},
     time::Duration,
 };
@@ -62,23 +59,12 @@ impl Priority {
 pub struct RunnableMeta {
     /// The source location where the task was spawned.
     pub location: &'static Location<'static>,
-    /// Shared flag indicating whether the scheduler has been closed.
-    /// When true, tasks should be dropped without running.
-    pub closed: Arc<AtomicBool>,
-}
-
-impl RunnableMeta {
-    /// Returns true if the scheduler has been closed and this task should not run.
-    pub fn is_closed(&self) -> bool {
-        self.closed.load(Ordering::SeqCst)
-    }
 }
 
 impl std::fmt::Debug for RunnableMeta {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("RunnableMeta")
             .field("location", &self.location)
-            .field("closed", &self.is_closed())
             .finish()
     }
 }

crates/scheduler/src/test_scheduler.rs 🔗

@@ -320,10 +320,6 @@ impl TestScheduler {
         };
 
         if let Some(runnable) = runnable {
-            // Check if the executor that spawned this task was closed
-            if runnable.runnable.metadata().is_closed() {
-                return true;
-            }
             let is_foreground = runnable.session_id.is_some();
             let was_main_thread = self.state.lock().is_main_thread;
             self.state.lock().is_main_thread = is_foreground;

crates/search/Cargo.toml 🔗

@@ -7,7 +7,7 @@ license = "GPL-3.0-or-later"
 
 [features]
 test-support = [
-    "client/test-support",
+
     "editor/test-support",
     "gpui/test-support",
     "workspace/test-support",
@@ -47,7 +47,6 @@ ztracing.workspace = true
 tracing.workspace = true
 
 [dev-dependencies]
-client = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }

crates/search/src/project_search.rs 🔗

@@ -1583,9 +1583,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("filter-paths", "Include/exclude specific paths")
-                    .icon(IconName::Filter)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::Filter).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleFilters.boxed_clone(), cx)
@@ -1593,9 +1591,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("find-replace", "Find and replace")
-                    .icon(IconName::Replace)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::Replace).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleReplace.boxed_clone(), cx)
@@ -1603,9 +1599,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("regex", "Match with regex")
-                    .icon(IconName::Regex)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::Regex).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleRegex.boxed_clone(), cx)
@@ -1613,9 +1607,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("match-case", "Match case")
-                    .icon(IconName::CaseSensitive)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(
                         &ToggleCaseSensitive,
                         &focus_handle,
@@ -1627,9 +1619,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("match-whole-words", "Match whole words")
-                    .icon(IconName::WholeWord)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(
                         &ToggleWholeWord,
                         &focus_handle,

crates/settings/src/vscode_import.rs 🔗

@@ -793,7 +793,12 @@ impl VsCodeSettings {
             hide_root: None,
             indent_guides: None,
             indent_size: None,
-            scrollbar: None,
+            scrollbar: self.read_bool("workbench.list.horizontalScrolling").map(
+                |horizontal_scrolling| ProjectPanelScrollbarSettingsContent {
+                    show: None,
+                    horizontal_scroll: Some(horizontal_scrolling),
+                },
+            ),
             show_diagnostics: self
                 .read_bool("problems.decorations.enabled")
                 .and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }),

crates/settings_content/src/agent.rs 🔗

@@ -9,6 +9,19 @@ use crate::ExtendingVec;
 
 use crate::DockPosition;
 
+/// Where new threads should start by default.
+#[derive(
+    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum NewThreadLocation {
+    /// Start threads in the current project.
+    #[default]
+    LocalProject,
+    /// Start threads in a new worktree.
+    NewWorktree,
+}
+
 #[with_fallible_options]
 #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
 pub struct AgentSettingsContent {
@@ -59,6 +72,10 @@ pub struct AgentSettingsContent {
     ///
     /// Default: "thread"
     pub default_view: Option<DefaultAgentView>,
+    /// Where new threads should start by default.
+    ///
+    /// Default: "local_project"
+    pub new_thread_location: Option<NewThreadLocation>,
     /// The available agent profiles.
     pub profiles: Option<IndexMap<Arc<str>, AgentProfileContent>>,
     /// Where to show a popup notification when the agent is waiting for user input.
@@ -146,6 +163,10 @@ impl AgentSettingsContent {
         self.default_profile = Some(profile_id);
     }
 
+    pub fn set_new_thread_location(&mut self, value: NewThreadLocation) {
+        self.new_thread_location = Some(value);
+    }
+
     pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
         if !self.favorite_models.contains(&model) {
             self.favorite_models.push(model);

crates/settings_content/src/language_model.rs 🔗

@@ -148,6 +148,7 @@ impl Default for KeepAlive {
 #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
 pub struct LmStudioSettingsContent {
     pub api_url: Option<String>,
+    pub api_key: Option<String>,
     pub available_models: Option<Vec<LmStudioAvailableModel>>,
 }
 

crates/settings_content/src/project.rs 🔗

@@ -1,5 +1,9 @@
-use std::{path::PathBuf, sync::Arc};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 
+use anyhow::Context;
 use collections::{BTreeMap, HashMap};
 use gpui::Rgba;
 use schemars::JsonSchema;
@@ -233,6 +237,26 @@ pub struct SemanticTokenRules {
     pub rules: Vec<SemanticTokenRule>,
 }
 
+impl SemanticTokenRules {
+    pub const FILE_NAME: &'static str = "semantic_token_rules.json";
+
+    pub fn load(file_path: &Path) -> anyhow::Result<Self> {
+        let rules_content = std::fs::read(file_path).with_context(|| {
+            anyhow::anyhow!(
+                "Could not read semantic token rules from {}",
+                file_path.display()
+            )
+        })?;
+
+        serde_json_lenient::from_slice::<SemanticTokenRules>(&rules_content).with_context(|| {
+            anyhow::anyhow!(
+                "Failed to parse semantic token rules from {}",
+                file_path.display()
+            )
+        })
+    }
+}
+
 impl crate::merge_from::MergeFrom for SemanticTokenRules {
     fn merge_from(&mut self, other: &Self) {
         self.rules.splice(0..0, other.rules.iter().cloned());

crates/settings_content/src/settings_content.rs 🔗

@@ -622,7 +622,7 @@ pub struct GitPanelSettingsContent {
 
     /// Whether to show the addition/deletion change count next to each file in the Git panel.
     ///
-    /// Default: false
+    /// Default: true
     pub diff_stats: Option<bool>,
 }
 
@@ -721,6 +721,10 @@ pub struct FileFinderSettingsContent {
     ///
     /// Default: Smart
     pub include_ignored: Option<IncludeIgnoredContent>,
+    /// Whether to include text channels in file finder results.
+    ///
+    /// Default: false
+    pub include_channels: Option<bool>,
 }
 
 #[derive(

crates/settings_content/src/workspace.rs 🔗

@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
 use settings_macros::{MergeFrom, with_fallible_options};
 
 use crate::{
-    CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity,
-    ScrollbarSettingsContent, ShowIndentGuides, serialize_optional_f32_with_two_decimal_places,
+    CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, ShowIndentGuides,
+    ShowScrollbar, serialize_optional_f32_with_two_decimal_places,
 };
 
 #[with_fallible_options]
@@ -710,7 +710,7 @@ pub struct ProjectPanelSettingsContent {
     /// Default: true
     pub starts_open: Option<bool>,
     /// Scrollbar-related settings
-    pub scrollbar: Option<ScrollbarSettingsContent>,
+    pub scrollbar: Option<ProjectPanelScrollbarSettingsContent>,
     /// Which files containing diagnostic errors/warnings to mark in the project panel.
     ///
     /// Default: all
@@ -793,6 +793,23 @@ pub enum ProjectPanelSortMode {
     FilesFirst,
 }
 
+#[with_fallible_options]
+#[derive(
+    Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,
+)]
+pub struct ProjectPanelScrollbarSettingsContent {
+    /// When to show the scrollbar in the project panel.
+    ///
+    /// Default: inherits editor scrollbar settings
+    pub show: Option<ShowScrollbar>,
+    /// Whether to allow horizontal scrolling in the project panel.
+    /// When false, the view is locked to the leftmost position and
+    /// long file names are clipped.
+    ///
+    /// Default: true
+    pub horizontal_scroll: Option<bool>,
+}
+
 #[with_fallible_options]
 #[derive(
     Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,

crates/settings_profile_selector/Cargo.toml 🔗

@@ -22,10 +22,8 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
-client = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
-language = { workspace = true, features = ["test-support"] }
 menu.workspace = true
 project = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true

crates/settings_ui/Cargo.toml 🔗

@@ -59,20 +59,13 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
-assets.workspace = true
-client.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 futures.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
-language.workspace = true
-node_runtime.workspace = true
 paths.workspace = true
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
-recent_projects = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
-session.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 title_bar = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
-zlog.workspace = true

crates/settings_ui/src/page_data.rs 🔗

@@ -4238,7 +4238,7 @@ fn window_and_layout_page() -> SettingsPage {
 }
 
 fn panels_page() -> SettingsPage {
-    fn project_panel_section() -> [SettingsPageItem; 22] {
+    fn project_panel_section() -> [SettingsPageItem; 23] {
         [
             SettingsPageItem::SectionHeader("Project Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -4516,6 +4516,32 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Horizontal Scroll",
+                description: "Whether to allow horizontal scrolling in the project panel. When disabled, the view is always locked to the leftmost position and long file names are clipped.",
+                field: Box::new(SettingField {
+                    json_path: Some("project_panel.scrollbar.horizontal_scroll"),
+                    pick: |settings_content| {
+                        settings_content
+                            .project_panel
+                            .as_ref()?
+                            .scrollbar
+                            .as_ref()?
+                            .horizontal_scroll
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .project_panel
+                            .get_or_insert_default()
+                            .scrollbar
+                            .get_or_insert_default()
+                            .horizontal_scroll = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Show Diagnostics",
                 description: "Which files containing diagnostic errors/warnings to mark in the project panel.",
@@ -6972,7 +6998,7 @@ fn ai_page() -> SettingsPage {
         ]
     }
 
-    fn agent_configuration_section() -> [SettingsPageItem; 12] {
+    fn agent_configuration_section() -> [SettingsPageItem; 13] {
         [
             SettingsPageItem::SectionHeader("Agent Configuration"),
             SettingsPageItem::SubPageLink(SubPageLink {
@@ -6984,6 +7010,28 @@ fn ai_page() -> SettingsPage {
                 files: USER,
                 render: render_tool_permissions_setup_page,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "New Thread Location",
+                description: "Whether to start a new thread in the current local project or in a new Git worktree.",
+                field: Box::new(SettingField {
+                    json_path: Some("agent.default_start_thread_in"),
+                    pick: |settings_content| {
+                        settings_content
+                            .agent
+                            .as_ref()?
+                            .new_thread_location
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .agent
+                            .get_or_insert_default()
+                            .new_thread_location = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Single File Review",
                 description: "When enabled, agent edits will also be displayed in single-file buffers for review.",

crates/settings_ui/src/pages/tool_permissions_setup.rs 🔗

@@ -275,10 +275,11 @@ fn render_tool_list_item(
                 .tab_index(tool_index as isize)
                 .style(ButtonStyle::OutlinedGhost)
                 .size(ButtonSize::Medium)
-                .icon(IconName::ChevronRight)
-                .icon_position(IconPosition::End)
-                .icon_color(Color::Muted)
-                .icon_size(IconSize::Small)
+                .end_icon(
+                    Icon::new(IconName::ChevronRight)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
                 .on_click(cx.listener(move |this, _, window, cx| {
                     this.push_dynamic_sub_page(
                         tool_name,
@@ -1090,9 +1091,7 @@ fn render_global_default_mode_section(current_mode: ToolPermissionMode) -> AnyEl
                         .tab_index(0_isize)
                         .style(ButtonStyle::Outlined)
                         .size(ButtonSize::Medium)
-                        .icon(IconName::ChevronDown)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small),
+                        .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)),
                 )
                 .menu(move |window, cx| {
                     Some(ContextMenu::build(window, cx, move |menu, _, _| {
@@ -1141,9 +1140,7 @@ fn render_default_mode_section(
                         .tab_index(0_isize)
                         .style(ButtonStyle::Outlined)
                         .size(ButtonSize::Medium)
-                        .icon(IconName::ChevronDown)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small),
+                        .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)),
                 )
                 .menu(move |window, cx| {
                     let tool_id = tool_id_owned.clone();

crates/settings_ui/src/settings_ui.rs 🔗

@@ -925,9 +925,7 @@ impl SettingsPageItem {
                         Button::new("error-warning", warning)
                             .style(ButtonStyle::Outlined)
                             .size(ButtonSize::Medium)
-                            .icon(Some(IconName::Debug))
-                            .icon_position(IconPosition::Start)
-                            .icon_color(Color::Error)
+                            .start_icon(Icon::new(IconName::Debug).color(Color::Error))
                             .tab_index(0_isize)
                             .tooltip(Tooltip::text(setting_item.field.type_name()))
                             .into_any_element(),
@@ -992,11 +990,12 @@ impl SettingsPageItem {
                                 ("sub-page".into(), sub_page_link.title.clone()),
                                 "Configure",
                             )
-                            .icon(IconName::ChevronRight)
                             .tab_index(0_isize)
-                            .icon_position(IconPosition::End)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::Small)
+                            .end_icon(
+                                Icon::new(IconName::ChevronRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .style(ButtonStyle::OutlinedGhost)
                             .size(ButtonSize::Medium)
                             .on_click({
@@ -1125,11 +1124,12 @@ impl SettingsPageItem {
                                 ("action-link".into(), action_link.title.clone()),
                                 action_link.button_text.clone(),
                             )
-                            .icon(IconName::ArrowUpRight)
                             .tab_index(0_isize)
-                            .icon_position(IconPosition::End)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::Small)
+                            .end_icon(
+                                Icon::new(IconName::ArrowUpRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .style(ButtonStyle::OutlinedGhost)
                             .size(ButtonSize::Medium)
                             .on_click({
@@ -4174,10 +4174,11 @@ fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button
         .tab_index(0_isize)
         .style(ButtonStyle::Outlined)
         .size(ButtonSize::Medium)
-        .icon(IconName::ChevronUpDown)
-        .icon_color(Color::Muted)
-        .icon_size(IconSize::Small)
-        .icon_position(IconPosition::End)
+        .end_icon(
+            Icon::new(IconName::ChevronUpDown)
+                .size(IconSize::Small)
+                .color(Color::Muted),
+        )
 }
 
 fn render_font_picker(

crates/sidebar/Cargo.toml 🔗

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

crates/sidebar/src/sidebar.rs 🔗

@@ -1,3136 +0,0 @@
-use acp_thread::ThreadStatus;
-use agent::ThreadStore;
-use agent_client_protocol as acp;
-use agent_ui::{AgentPanel, AgentPanelEvent, NewThread};
-use chrono::Utc;
-use editor::{Editor, EditorElement, EditorStyle};
-use gpui::{
-    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState,
-    Pixels, Render, SharedString, Subscription, TextStyle, WeakEntity, Window, actions, list,
-    prelude::*, px, relative, rems,
-};
-use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use project::Event as ProjectEvent;
-use settings::Settings;
-use std::collections::{HashMap, HashSet};
-use std::mem;
-use theme::{ActiveTheme, ThemeSettings};
-use ui::utils::TRAFFIC_LIGHT_PADDING;
-use ui::{
-    AgentThreadStatus, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, PopoverMenu, Tab,
-    ThreadItem, Tooltip, WithScrollbar, prelude::*,
-};
-use util::path_list::PathList;
-use workspace::{
-    FocusWorkspaceSidebar, MultiWorkspace, Sidebar as WorkspaceSidebar, SidebarEvent,
-    ToggleWorkspaceSidebar, Workspace,
-};
-use zed_actions::editor::{MoveDown, MoveUp};
-
-actions!(
-    agents_sidebar,
-    [
-        /// Collapses the selected entry in the workspace sidebar.
-        CollapseSelectedEntry,
-        /// Expands the selected entry in the workspace sidebar.
-        ExpandSelectedEntry,
-    ]
-);
-
-const DEFAULT_WIDTH: Pixels = px(320.0);
-const MIN_WIDTH: Pixels = px(200.0);
-const MAX_WIDTH: Pixels = px(800.0);
-const DEFAULT_THREADS_SHOWN: usize = 5;
-
-#[derive(Clone, Debug)]
-struct ActiveThreadInfo {
-    session_id: acp::SessionId,
-    title: SharedString,
-    status: AgentThreadStatus,
-    icon: IconName,
-    icon_from_external_svg: Option<SharedString>,
-    is_background: bool,
-}
-
-impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
-    fn from(info: &ActiveThreadInfo) -> Self {
-        Self {
-            session_id: info.session_id.clone(),
-            cwd: None,
-            title: Some(info.title.clone()),
-            updated_at: Some(Utc::now()),
-            meta: None,
-        }
-    }
-}
-
-#[derive(Clone, Debug)]
-#[allow(dead_code)]
-enum ListEntry {
-    ProjectHeader {
-        path_list: PathList,
-        label: SharedString,
-        highlight_positions: Vec<usize>,
-    },
-    Thread {
-        session_info: acp_thread::AgentSessionInfo,
-        icon: IconName,
-        icon_from_external_svg: Option<SharedString>,
-        status: AgentThreadStatus,
-        diff_stats: Option<(usize, usize)>,
-        workspace_index: usize,
-        is_live: bool,
-        is_background: bool,
-        highlight_positions: Vec<usize>,
-    },
-    ViewMore {
-        path_list: PathList,
-        remaining_count: usize,
-    },
-    NewThread {
-        path_list: PathList,
-    },
-}
-
-#[derive(Default)]
-struct SidebarContents {
-    entries: Vec<ListEntry>,
-    notified_threads: HashSet<acp::SessionId>,
-}
-
-impl SidebarContents {
-    fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool {
-        self.notified_threads.contains(session_id)
-    }
-}
-
-fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
-    let mut positions = Vec::new();
-    let mut query_chars = query.chars().peekable();
-
-    for (byte_idx, candidate_char) in candidate.char_indices() {
-        if let Some(&query_char) = query_chars.peek() {
-            if candidate_char.eq_ignore_ascii_case(&query_char) {
-                positions.push(byte_idx);
-                query_chars.next();
-            }
-        } else {
-            break;
-        }
-    }
-
-    if query_chars.peek().is_none() {
-        Some(positions)
-    } else {
-        None
-    }
-}
-
-fn workspace_path_list_and_label(
-    workspace: &Entity<Workspace>,
-    cx: &App,
-) -> (PathList, SharedString) {
-    let workspace_ref = workspace.read(cx);
-    let mut paths = Vec::new();
-    let mut names = Vec::new();
-
-    for worktree in workspace_ref.worktrees(cx) {
-        let worktree_ref = worktree.read(cx);
-        if !worktree_ref.is_visible() {
-            continue;
-        }
-        let abs_path = worktree_ref.abs_path();
-        paths.push(abs_path.to_path_buf());
-        if let Some(name) = abs_path.file_name() {
-            names.push(name.to_string_lossy().to_string());
-        }
-    }
-
-    let label: SharedString = if names.is_empty() {
-        // TODO: Can we do something better in this case?
-        "Empty Workspace".into()
-    } else {
-        names.join(", ").into()
-    };
-
-    (PathList::new(&paths), label)
-}
-
-fn workspace_index_for_path_list(
-    workspaces: &[Entity<Workspace>],
-    path_list: &PathList,
-    cx: &App,
-) -> Option<usize> {
-    workspaces
-        .iter()
-        .enumerate()
-        .find_map(|(index, workspace)| {
-            let (candidate, _) = workspace_path_list_and_label(workspace, cx);
-            (candidate == *path_list).then_some(index)
-        })
-}
-
-pub struct Sidebar {
-    multi_workspace: WeakEntity<MultiWorkspace>,
-    width: Pixels,
-    focus_handle: FocusHandle,
-    filter_editor: Entity<Editor>,
-    list_state: ListState,
-    contents: SidebarContents,
-    selection: Option<usize>,
-    collapsed_groups: HashSet<PathList>,
-    expanded_groups: HashSet<PathList>,
-    _subscriptions: Vec<Subscription>,
-    _project_subscriptions: Vec<Subscription>,
-    _agent_panel_subscriptions: Vec<Subscription>,
-    _thread_store_subscription: Option<Subscription>,
-}
-
-impl EventEmitter<SidebarEvent> for Sidebar {}
-
-impl Sidebar {
-    pub fn new(
-        multi_workspace: Entity<MultiWorkspace>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let focus_handle = cx.focus_handle();
-        cx.on_focus_in(&focus_handle, window, Self::focus_in)
-            .detach();
-
-        let filter_editor = cx.new(|cx| {
-            let mut editor = Editor::single_line(window, cx);
-            editor.set_placeholder_text("Search threads…", window, cx);
-            editor
-        });
-
-        let observe_subscription = cx.observe_in(
-            &multi_workspace,
-            window,
-            |this, _multi_workspace, window, cx| {
-                this.update_entries(window, cx);
-            },
-        );
-
-        let filter_subscription = cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
-            if let editor::EditorEvent::BufferEdited = event {
-                let query = this.filter_editor.read(cx).text(cx);
-                if !query.is_empty() {
-                    this.selection.take();
-                }
-                this.rebuild_contents(cx);
-                this.list_state.reset(this.contents.entries.len());
-                if !query.is_empty() {
-                    this.selection = this
-                        .contents
-                        .entries
-                        .iter()
-                        .position(|entry| matches!(entry, ListEntry::Thread { .. }))
-                        .or_else(|| {
-                            if this.contents.entries.is_empty() {
-                                None
-                            } else {
-                                Some(0)
-                            }
-                        });
-                }
-                cx.notify();
-            }
-        });
-
-        let mut this = Self {
-            multi_workspace: multi_workspace.downgrade(),
-            width: DEFAULT_WIDTH,
-            focus_handle,
-            filter_editor,
-            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
-            contents: SidebarContents::default(),
-            selection: None,
-            collapsed_groups: HashSet::new(),
-            expanded_groups: HashSet::new(),
-            _subscriptions: vec![observe_subscription, filter_subscription],
-            _project_subscriptions: Vec::new(),
-            _agent_panel_subscriptions: Vec::new(),
-            _thread_store_subscription: None,
-        };
-        this.update_entries(window, cx);
-        this
-    }
-
-    fn subscribe_to_projects(
-        &mut self,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Vec<Subscription> {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return Vec::new();
-        };
-        let projects: Vec<_> = multi_workspace
-            .read(cx)
-            .workspaces()
-            .iter()
-            .map(|w| w.read(cx).project().clone())
-            .collect();
-
-        projects
-            .iter()
-            .map(|project| {
-                cx.subscribe_in(
-                    project,
-                    window,
-                    |this, _project, event, window, cx| match event {
-                        ProjectEvent::WorktreeAdded(_)
-                        | ProjectEvent::WorktreeRemoved(_)
-                        | ProjectEvent::WorktreeOrderChanged => {
-                            this.update_entries(window, cx);
-                        }
-                        _ => {}
-                    },
-                )
-            })
-            .collect()
-    }
-
-    fn subscribe_to_agent_panels(
-        &mut self,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Vec<Subscription> {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return Vec::new();
-        };
-        let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().to_vec();
-
-        workspaces
-            .iter()
-            .map(|workspace| {
-                if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
-                    cx.subscribe_in(
-                        &agent_panel,
-                        window,
-                        |this, _, _event: &AgentPanelEvent, window, cx| {
-                            this.update_entries(window, cx);
-                        },
-                    )
-                } else {
-                    cx.observe_in(workspace, window, |this, _, window, cx| {
-                        this.update_entries(window, cx);
-                    })
-                }
-            })
-            .collect()
-    }
-
-    fn subscribe_to_thread_store(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if self._thread_store_subscription.is_some() {
-            return;
-        }
-        if let Some(thread_store) = ThreadStore::try_global(cx) {
-            self._thread_store_subscription =
-                Some(cx.observe_in(&thread_store, window, |this, _, window, cx| {
-                    this.update_entries(window, cx);
-                }));
-        }
-    }
-
-    fn all_thread_infos_for_workspace(
-        workspace: &Entity<Workspace>,
-        cx: &App,
-    ) -> Vec<ActiveThreadInfo> {
-        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
-            return Vec::new();
-        };
-        let agent_panel_ref = agent_panel.read(cx);
-
-        agent_panel_ref
-            .parent_threads(cx)
-            .into_iter()
-            .map(|thread_view| {
-                let thread_view_ref = thread_view.read(cx);
-                let thread = thread_view_ref.thread.read(cx);
-
-                let icon = thread_view_ref.agent_icon;
-                let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
-                let title = thread.title();
-                let session_id = thread.session_id().clone();
-                let is_background = agent_panel_ref.is_background_thread(&session_id);
-
-                let status = if thread.is_waiting_for_confirmation() {
-                    AgentThreadStatus::WaitingForConfirmation
-                } else if thread.had_error() {
-                    AgentThreadStatus::Error
-                } else {
-                    match thread.status() {
-                        ThreadStatus::Generating => AgentThreadStatus::Running,
-                        ThreadStatus::Idle => AgentThreadStatus::Completed,
-                    }
-                };
-
-                ActiveThreadInfo {
-                    session_id,
-                    title,
-                    status,
-                    icon,
-                    icon_from_external_svg,
-                    is_background,
-                }
-            })
-            .collect()
-    }
-
-    fn rebuild_contents(&mut self, cx: &App) {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return;
-        };
-        let mw = multi_workspace.read(cx);
-        let workspaces = mw.workspaces().to_vec();
-        let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
-        let active_workspace_index = active_workspace
-            .and_then(|active| {
-                workspaces
-                    .iter()
-                    .position(|w| w.entity_id() == active.entity_id())
-            })
-            .unwrap_or(0);
-
-        let thread_store = ThreadStore::try_global(cx);
-        let query = self.filter_editor.read(cx).text(cx);
-
-        let previous = mem::take(&mut self.contents);
-
-        let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
-            .entries
-            .iter()
-            .filter_map(|entry| match entry {
-                ListEntry::Thread {
-                    session_info,
-                    status,
-                    is_live: true,
-                    ..
-                } => Some((session_info.session_id.clone(), *status)),
-                _ => None,
-            })
-            .collect();
-
-        let mut entries = Vec::new();
-        let mut notified_threads = previous.notified_threads;
-
-        for (index, workspace) in workspaces.iter().enumerate() {
-            let (path_list, label) = workspace_path_list_and_label(workspace, cx);
-
-            let is_collapsed = self.collapsed_groups.contains(&path_list);
-            let should_load_threads = !is_collapsed || !query.is_empty();
-
-            let mut threads: Vec<ListEntry> = Vec::new();
-
-            if should_load_threads {
-                if let Some(ref thread_store) = thread_store {
-                    for meta in thread_store.read(cx).threads_for_paths(&path_list) {
-                        threads.push(ListEntry::Thread {
-                            session_info: meta.into(),
-                            icon: IconName::ZedAgent,
-                            icon_from_external_svg: None,
-                            status: AgentThreadStatus::default(),
-                            diff_stats: None,
-                            workspace_index: index,
-                            is_live: false,
-                            is_background: false,
-                            highlight_positions: Vec::new(),
-                        });
-                    }
-                }
-
-                let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
-
-                for info in &live_infos {
-                    let Some(existing) = threads.iter_mut().find(|t| {
-                        matches!(t, ListEntry::Thread { session_info, .. } if session_info.session_id == info.session_id)
-                    }) else {
-                        continue;
-                    };
-
-                    if let ListEntry::Thread {
-                        session_info,
-                        status,
-                        icon,
-                        icon_from_external_svg,
-                        workspace_index: _,
-                        is_live,
-                        is_background,
-                        ..
-                    } = existing
-                    {
-                        session_info.title = Some(info.title.clone());
-                        *status = info.status;
-                        *icon = info.icon;
-                        *icon_from_external_svg = info.icon_from_external_svg.clone();
-                        *is_live = true;
-                        *is_background = info.is_background;
-                    }
-                }
-
-                // Update notification state for live threads.
-                for thread in &threads {
-                    if let ListEntry::Thread {
-                        workspace_index,
-                        session_info,
-                        status,
-                        is_background,
-                        ..
-                    } = thread
-                    {
-                        let session_id = &session_info.session_id;
-                        if *is_background && *status == AgentThreadStatus::Completed {
-                            notified_threads.insert(session_id.clone());
-                        } else if *status == AgentThreadStatus::Completed
-                            && *workspace_index != active_workspace_index
-                            && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
-                        {
-                            notified_threads.insert(session_id.clone());
-                        }
-
-                        if *workspace_index == active_workspace_index && !*is_background {
-                            notified_threads.remove(session_id);
-                        }
-                    }
-                }
-
-                threads.sort_by(|a, b| {
-                    let a_time = match a {
-                        ListEntry::Thread { session_info, .. } => session_info.updated_at,
-                        _ => unreachable!(),
-                    };
-                    let b_time = match b {
-                        ListEntry::Thread { session_info, .. } => session_info.updated_at,
-                        _ => unreachable!(),
-                    };
-                    b_time.cmp(&a_time)
-                });
-            }
-
-            if !query.is_empty() {
-                let mut matched_threads = Vec::new();
-                for mut thread in threads {
-                    if let ListEntry::Thread {
-                        session_info,
-                        highlight_positions,
-                        ..
-                    } = &mut thread
-                    {
-                        let title = session_info
-                            .title
-                            .as_ref()
-                            .map(|s| s.as_ref())
-                            .unwrap_or("");
-                        if let Some(positions) = fuzzy_match_positions(&query, title) {
-                            *highlight_positions = positions;
-                            matched_threads.push(thread);
-                        }
-                    }
-                }
-
-                let workspace_highlight_positions =
-                    fuzzy_match_positions(&query, &label).unwrap_or_default();
-
-                if matched_threads.is_empty() && workspace_highlight_positions.is_empty() {
-                    continue;
-                }
-
-                entries.push(ListEntry::ProjectHeader {
-                    path_list: path_list.clone(),
-                    label,
-                    highlight_positions: workspace_highlight_positions,
-                });
-                entries.extend(matched_threads);
-            } else {
-                entries.push(ListEntry::ProjectHeader {
-                    path_list: path_list.clone(),
-                    label,
-                    highlight_positions: Vec::new(),
-                });
-
-                if is_collapsed {
-                    continue;
-                }
-
-                let total = threads.len();
-                let show_view_more =
-                    total > DEFAULT_THREADS_SHOWN && !self.expanded_groups.contains(&path_list);
-
-                let count = if show_view_more {
-                    DEFAULT_THREADS_SHOWN
-                } else {
-                    total
-                };
-
-                entries.extend(threads.into_iter().take(count));
-
-                if show_view_more {
-                    entries.push(ListEntry::ViewMore {
-                        path_list: path_list.clone(),
-                        remaining_count: total - DEFAULT_THREADS_SHOWN,
-                    });
-                }
-
-                if total == 0 {
-                    entries.push(ListEntry::NewThread {
-                        path_list: path_list.clone(),
-                    });
-                }
-            }
-        }
-
-        // Prune stale entries from notified_threads.
-        let current_session_ids: HashSet<&acp::SessionId> = entries
-            .iter()
-            .filter_map(|e| match e {
-                ListEntry::Thread { session_info, .. } => Some(&session_info.session_id),
-                _ => None,
-            })
-            .collect();
-        notified_threads.retain(|id| current_session_ids.contains(id));
-
-        self.contents = SidebarContents {
-            entries,
-            notified_threads,
-        };
-    }
-
-    fn update_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let multi_workspace = self.multi_workspace.clone();
-        cx.defer_in(window, move |this, window, cx| {
-            let Some(multi_workspace) = multi_workspace.upgrade() else {
-                return;
-            };
-            if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
-                return;
-            }
-
-            this._project_subscriptions = this.subscribe_to_projects(window, cx);
-            this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx);
-            this.subscribe_to_thread_store(window, cx);
-
-            let had_notifications = this.has_notifications(cx);
-
-            this.rebuild_contents(cx);
-
-            this.list_state.reset(this.contents.entries.len());
-
-            if let Some(selection) = this.selection {
-                if selection >= this.contents.entries.len() {
-                    this.selection = this.contents.entries.len().checked_sub(1);
-                }
-            }
-
-            if had_notifications != this.has_notifications(cx) {
-                multi_workspace.update(cx, |_, cx| {
-                    cx.notify();
-                });
-            }
-
-            cx.notify();
-        });
-    }
-
-    fn render_list_entry(
-        &mut self,
-        ix: usize,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> AnyElement {
-        let Some(entry) = self.contents.entries.get(ix) else {
-            return div().into_any_element();
-        };
-        let is_focused = self.focus_handle.is_focused(window)
-            || self.filter_editor.focus_handle(cx).is_focused(window);
-        let is_selected = is_focused && self.selection == Some(ix);
-
-        let is_group_header_after_first =
-            ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
-
-        let rendered = match entry {
-            ListEntry::ProjectHeader {
-                path_list,
-                label,
-                highlight_positions,
-            } => self.render_project_header(
-                ix,
-                path_list,
-                label,
-                highlight_positions,
-                is_selected,
-                cx,
-            ),
-            ListEntry::Thread {
-                session_info,
-                icon,
-                icon_from_external_svg,
-                status,
-                workspace_index,
-                highlight_positions,
-                ..
-            } => self.render_thread(
-                ix,
-                session_info,
-                *icon,
-                icon_from_external_svg.clone(),
-                *status,
-                *workspace_index,
-                highlight_positions,
-                is_selected,
-                cx,
-            ),
-            ListEntry::ViewMore {
-                path_list,
-                remaining_count,
-            } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx),
-            ListEntry::NewThread { path_list } => {
-                self.render_new_thread(ix, path_list, is_selected, cx)
-            }
-        };
-
-        if is_group_header_after_first {
-            v_flex()
-                .w_full()
-                .border_t_1()
-                .border_color(cx.theme().colors().border_variant)
-                .child(rendered)
-                .into_any_element()
-        } else {
-            rendered
-        }
-    }
-
-    fn render_project_header(
-        &self,
-        ix: usize,
-        path_list: &PathList,
-        label: &SharedString,
-        highlight_positions: &[usize],
-        is_selected: bool,
-        cx: &mut Context<Self>,
-    ) -> AnyElement {
-        let id = SharedString::from(format!("project-header-{}", ix));
-        let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix));
-        let group = SharedString::from(format!("group-{}", ix));
-
-        let is_collapsed = self.collapsed_groups.contains(path_list);
-        let disclosure_icon = if is_collapsed {
-            IconName::ChevronRight
-        } else {
-            IconName::ChevronDown
-        };
-        let path_list_for_new_thread = path_list.clone();
-        let path_list_for_remove = path_list.clone();
-        let path_list_for_toggle = path_list.clone();
-        let workspace_count = self
-            .multi_workspace
-            .upgrade()
-            .map_or(0, |mw| mw.read(cx).workspaces().len());
-
-        ListItem::new(id)
-            .group_name(&group)
-            .toggle_state(is_selected)
-            .child(
-                h_flex()
-                    .px_1()
-                    .py_1p5()
-                    .gap_0p5()
-                    .child(if highlight_positions.is_empty() {
-                        Label::new(label.clone())
-                            .size(LabelSize::Small)
-                            .color(Color::Muted)
-                            .into_any_element()
-                    } else {
-                        HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
-                            .size(LabelSize::Small)
-                            .color(Color::Muted)
-                            .into_any_element()
-                    })
-                    .child(
-                        div().visible_on_hover(group).child(
-                            Icon::new(disclosure_icon)
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                        ),
-                    ),
-            )
-            .end_hover_slot(
-                h_flex()
-                    .gap_0p5()
-                    .child(
-                        IconButton::new(ib_id, IconName::NewThread)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .tooltip(Tooltip::text("New Thread"))
-                            .on_click(cx.listener(move |this, _, window, cx| {
-                                this.selection = None;
-                                this.create_new_thread(&path_list_for_new_thread, window, cx);
-                            })),
-                    )
-                    .when(workspace_count > 1, |this| {
-                        this.child(
-                            IconButton::new(
-                                SharedString::from(format!("project-header-remove-{}", ix)),
-                                IconName::Close,
-                            )
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .tooltip(Tooltip::text("Remove Project"))
-                            .on_click(cx.listener(
-                                move |this, _, window, cx| {
-                                    this.remove_workspace(&path_list_for_remove, window, cx);
-                                },
-                            )),
-                        )
-                    }),
-            )
-            .on_click(cx.listener(move |this, _, window, cx| {
-                this.selection = None;
-                this.toggle_collapse(&path_list_for_toggle, window, cx);
-            }))
-            .into_any_element()
-    }
-
-    fn remove_workspace(
-        &mut self,
-        path_list: &PathList,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return;
-        };
-        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
-
-        let Some(workspace_index) = workspace_index_for_path_list(&workspaces, path_list, cx)
-        else {
-            return;
-        };
-
-        multi_workspace.update(cx, |multi_workspace, cx| {
-            multi_workspace.remove_workspace(workspace_index, window, cx);
-        });
-    }
-
-    fn toggle_collapse(
-        &mut self,
-        path_list: &PathList,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if self.collapsed_groups.contains(path_list) {
-            self.collapsed_groups.remove(path_list);
-        } else {
-            self.collapsed_groups.insert(path_list.clone());
-        }
-        self.update_entries(window, cx);
-    }
-
-    fn focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
-        if self.selection.is_none() && !self.contents.entries.is_empty() {
-            self.selection = Some(0);
-            cx.notify();
-        }
-    }
-
-    fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
-        if self.reset_filter_editor_text(window, cx) {
-            self.update_entries(window, cx);
-        } else {
-            self.focus_handle.focus(window, cx);
-        }
-    }
-
-    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
-        self.filter_editor.update(cx, |editor, cx| {
-            if editor.buffer().read(cx).len(cx).0 > 0 {
-                editor.set_text("", window, cx);
-                true
-            } else {
-                false
-            }
-        })
-    }
-
-    fn filter_query(&self, cx: &App) -> String {
-        self.filter_editor.read(cx).text(cx)
-    }
-
-    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
-        self.select_next(&SelectNext, window, cx);
-    }
-
-    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
-        self.select_previous(&SelectPrevious, window, cx);
-    }
-
-    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
-        let next = match self.selection {
-            Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1,
-            None if !self.contents.entries.is_empty() => 0,
-            _ => return,
-        };
-        self.selection = Some(next);
-        self.list_state.scroll_to_reveal_item(next);
-        cx.notify();
-    }
-
-    fn select_previous(
-        &mut self,
-        _: &SelectPrevious,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let prev = match self.selection {
-            Some(ix) if ix > 0 => ix - 1,
-            None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1,
-            _ => return,
-        };
-        self.selection = Some(prev);
-        self.list_state.scroll_to_reveal_item(prev);
-        cx.notify();
-    }
-
-    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
-        if !self.contents.entries.is_empty() {
-            self.selection = Some(0);
-            self.list_state.scroll_to_reveal_item(0);
-            cx.notify();
-        }
-    }
-
-    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
-        if let Some(last) = self.contents.entries.len().checked_sub(1) {
-            self.selection = Some(last);
-            self.list_state.scroll_to_reveal_item(last);
-            cx.notify();
-        }
-    }
-
-    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(ix) = self.selection else { return };
-        let Some(entry) = self.contents.entries.get(ix) else {
-            return;
-        };
-
-        match entry {
-            ListEntry::ProjectHeader { path_list, .. } => {
-                let path_list = path_list.clone();
-                self.toggle_collapse(&path_list, window, cx);
-            }
-            ListEntry::Thread {
-                session_info,
-                workspace_index,
-                ..
-            } => {
-                let session_info = session_info.clone();
-                let workspace_index = *workspace_index;
-                self.activate_thread(session_info, workspace_index, window, cx);
-            }
-            ListEntry::ViewMore { path_list, .. } => {
-                let path_list = path_list.clone();
-                self.expanded_groups.insert(path_list);
-                self.update_entries(window, cx);
-            }
-            ListEntry::NewThread { path_list } => {
-                let path_list = path_list.clone();
-                self.create_new_thread(&path_list, window, cx);
-            }
-        }
-    }
-
-    fn activate_thread(
-        &mut self,
-        session_info: acp_thread::AgentSessionInfo,
-        workspace_index: usize,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return;
-        };
-
-        multi_workspace.update(cx, |multi_workspace, cx| {
-            multi_workspace.activate_index(workspace_index, window, cx);
-        });
-        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
-        if let Some(workspace) = workspaces.get(workspace_index) {
-            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
-                agent_panel.update(cx, |panel, cx| {
-                    panel.load_agent_thread(session_info, window, cx);
-                });
-            }
-        }
-    }
-
-    fn expand_selected_entry(
-        &mut self,
-        _: &ExpandSelectedEntry,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(ix) = self.selection else { return };
-
-        match self.contents.entries.get(ix) {
-            Some(ListEntry::ProjectHeader { path_list, .. }) => {
-                if self.collapsed_groups.contains(path_list) {
-                    let path_list = path_list.clone();
-                    self.collapsed_groups.remove(&path_list);
-                    self.update_entries(window, cx);
-                } else if ix + 1 < self.contents.entries.len() {
-                    self.selection = Some(ix + 1);
-                    self.list_state.scroll_to_reveal_item(ix + 1);
-                    cx.notify();
-                }
-            }
-            _ => {}
-        }
-    }
-
-    fn collapse_selected_entry(
-        &mut self,
-        _: &CollapseSelectedEntry,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(ix) = self.selection else { return };
-
-        match self.contents.entries.get(ix) {
-            Some(ListEntry::ProjectHeader { path_list, .. }) => {
-                if !self.collapsed_groups.contains(path_list) {
-                    let path_list = path_list.clone();
-                    self.collapsed_groups.insert(path_list);
-                    self.update_entries(window, cx);
-                }
-            }
-            Some(
-                ListEntry::Thread { .. } | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
-            ) => {
-                for i in (0..ix).rev() {
-                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
-                        self.contents.entries.get(i)
-                    {
-                        let path_list = path_list.clone();
-                        self.selection = Some(i);
-                        self.collapsed_groups.insert(path_list);
-                        self.update_entries(window, cx);
-                        break;
-                    }
-                }
-            }
-            None => {}
-        }
-    }
-
-    fn render_thread(
-        &self,
-        ix: usize,
-        session_info: &acp_thread::AgentSessionInfo,
-        icon: IconName,
-        icon_from_external_svg: Option<SharedString>,
-        status: AgentThreadStatus,
-        workspace_index: usize,
-        highlight_positions: &[usize],
-        is_selected: bool,
-        cx: &mut Context<Self>,
-    ) -> AnyElement {
-        let has_notification = self.contents.is_thread_notified(&session_info.session_id);
-
-        let title: SharedString = session_info
-            .title
-            .clone()
-            .unwrap_or_else(|| "Untitled".into());
-        let session_info = session_info.clone();
-
-        let id = SharedString::from(format!("thread-entry-{}", ix));
-        ThreadItem::new(id, title)
-            .icon(icon)
-            .when_some(icon_from_external_svg, |this, svg| {
-                this.custom_icon_from_external_svg(svg)
-            })
-            .highlight_positions(highlight_positions.to_vec())
-            .status(status)
-            .notified(has_notification)
-            .selected(is_selected)
-            .on_click(cx.listener(move |this, _, window, cx| {
-                this.selection = None;
-                this.activate_thread(session_info.clone(), workspace_index, window, cx);
-            }))
-            .into_any_element()
-    }
-
-    fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
-        let settings = ThemeSettings::get_global(cx);
-        let text_style = TextStyle {
-            color: cx.theme().colors().text,
-            font_family: settings.ui_font.family.clone(),
-            font_features: settings.ui_font.features.clone(),
-            font_fallbacks: settings.ui_font.fallbacks.clone(),
-            font_size: rems(0.875).into(),
-            font_weight: settings.ui_font.weight,
-            font_style: FontStyle::Normal,
-            line_height: relative(1.3),
-            ..Default::default()
-        };
-
-        EditorElement::new(
-            &self.filter_editor,
-            EditorStyle {
-                local_player: cx.theme().players().local(),
-                text: text_style,
-                ..Default::default()
-            },
-        )
-    }
-
-    fn render_view_more(
-        &self,
-        ix: usize,
-        path_list: &PathList,
-        remaining_count: usize,
-        is_selected: bool,
-        cx: &mut Context<Self>,
-    ) -> AnyElement {
-        let path_list = path_list.clone();
-        let id = SharedString::from(format!("view-more-{}", ix));
-
-        let count = format!("({})", remaining_count);
-
-        ListItem::new(id)
-            .toggle_state(is_selected)
-            .child(
-                h_flex()
-                    .px_1()
-                    .py_1p5()
-                    .gap_1p5()
-                    .child(
-                        Icon::new(IconName::Plus)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .child(Label::new("View More"))
-                    .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)),
-            )
-            .on_click(cx.listener(move |this, _, window, cx| {
-                this.selection = None;
-                this.expanded_groups.insert(path_list.clone());
-                this.update_entries(window, cx);
-            }))
-            .into_any_element()
-    }
-
-    fn create_new_thread(
-        &mut self,
-        path_list: &PathList,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return;
-        };
-        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
-
-        let workspace_index = workspace_index_for_path_list(&workspaces, path_list, cx);
-
-        let Some(workspace_index) = workspace_index else {
-            return;
-        };
-
-        multi_workspace.update(cx, |multi_workspace, cx| {
-            multi_workspace.activate_index(workspace_index, window, cx);
-        });
-
-        if let Some(workspace) = workspaces.get(workspace_index) {
-            workspace.update(cx, |workspace, cx| {
-                if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
-                    agent_panel.update(cx, |panel, cx| {
-                        panel.new_thread(&NewThread, window, cx);
-                    });
-                }
-                workspace.focus_panel::<AgentPanel>(window, cx);
-            });
-        }
-    }
-
-    fn render_new_thread(
-        &self,
-        ix: usize,
-        path_list: &PathList,
-        is_selected: bool,
-        cx: &mut Context<Self>,
-    ) -> AnyElement {
-        let path_list = path_list.clone();
-
-        div()
-            .w_full()
-            .p_2()
-            .child(
-                Button::new(
-                    SharedString::from(format!("new-thread-btn-{}", ix)),
-                    "New Thread",
-                )
-                .full_width()
-                .style(ButtonStyle::Outlined)
-                .icon(IconName::Plus)
-                .icon_color(Color::Muted)
-                .icon_size(IconSize::Small)
-                .icon_position(IconPosition::Start)
-                .toggle_state(is_selected)
-                .on_click(cx.listener(move |this, _, window, cx| {
-                    this.selection = None;
-                    this.create_new_thread(&path_list, window, cx);
-                })),
-            )
-            .into_any_element()
-    }
-}
-
-impl WorkspaceSidebar for Sidebar {
-    fn width(&self, _cx: &App) -> Pixels {
-        self.width
-    }
-
-    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
-        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
-        cx.notify();
-    }
-
-    fn has_notifications(&self, _cx: &App) -> bool {
-        !self.contents.notified_threads.is_empty()
-    }
-}
-
-impl Focusable for Sidebar {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.filter_editor.focus_handle(cx)
-    }
-}
-
-impl Render for Sidebar {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let titlebar_height = ui::utils::platform_title_bar_height(window);
-        let ui_font = theme::setup_ui_font(window, cx);
-        let is_focused = self.focus_handle.is_focused(window)
-            || self.filter_editor.focus_handle(cx).is_focused(window);
-        let has_query = !self.filter_query(cx).is_empty();
-
-        let focus_tooltip_label = if is_focused {
-            "Focus Workspace"
-        } else {
-            "Focus Sidebar"
-        };
-
-        v_flex()
-            .id("workspace-sidebar")
-            .key_context("WorkspaceSidebar")
-            .track_focus(&self.focus_handle)
-            .on_action(cx.listener(Self::select_next))
-            .on_action(cx.listener(Self::select_previous))
-            .on_action(cx.listener(Self::editor_move_down))
-            .on_action(cx.listener(Self::editor_move_up))
-            .on_action(cx.listener(Self::select_first))
-            .on_action(cx.listener(Self::select_last))
-            .on_action(cx.listener(Self::confirm))
-            .on_action(cx.listener(Self::expand_selected_entry))
-            .on_action(cx.listener(Self::collapse_selected_entry))
-            .on_action(cx.listener(Self::cancel))
-            .font(ui_font)
-            .h_full()
-            .w(self.width)
-            .bg(cx.theme().colors().surface_background)
-            .border_r_1()
-            .border_color(cx.theme().colors().border)
-            .child(
-                h_flex()
-                    .flex_none()
-                    .h(titlebar_height)
-                    .w_full()
-                    .mt_px()
-                    .pb_px()
-                    .pr_1()
-                    .when_else(
-                        cfg!(target_os = "macos") && !window.is_fullscreen(),
-                        |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
-                        |this| this.pl_2(),
-                    )
-                    .justify_between()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .child({
-                        let focus_handle_toggle = self.focus_handle.clone();
-                        let focus_handle_focus = self.focus_handle.clone();
-                        IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::element(move |_, cx| {
-                                v_flex()
-                                    .gap_1()
-                                    .child(
-                                        h_flex()
-                                            .gap_2()
-                                            .justify_between()
-                                            .child(Label::new("Close Sidebar"))
-                                            .child(KeyBinding::for_action_in(
-                                                &ToggleWorkspaceSidebar,
-                                                &focus_handle_toggle,
-                                                cx,
-                                            )),
-                                    )
-                                    .child(
-                                        h_flex()
-                                            .pt_1()
-                                            .gap_2()
-                                            .border_t_1()
-                                            .border_color(cx.theme().colors().border_variant)
-                                            .justify_between()
-                                            .child(Label::new(focus_tooltip_label))
-                                            .child(KeyBinding::for_action_in(
-                                                &FocusWorkspaceSidebar,
-                                                &focus_handle_focus,
-                                                cx,
-                                            )),
-                                    )
-                                    .into_any_element()
-                            }))
-                            .on_click(cx.listener(|_this, _, _window, cx| {
-                                cx.emit(SidebarEvent::Close);
-                            }))
-                    })
-                    .child({
-                        let workspace = self
-                            .multi_workspace
-                            .upgrade()
-                            .map(|mw| mw.read(cx).workspace().downgrade());
-                        let focus_handle = workspace
-                            .as_ref()
-                            .and_then(|w| w.upgrade())
-                            .map(|w| w.read(cx).focus_handle(cx))
-                            .unwrap_or_else(|| cx.focus_handle());
-
-                        PopoverMenu::new("sidebar-recent-projects-menu")
-                            .menu(move |window, cx| {
-                                let workspace = workspace.clone()?;
-                                Some(recent_projects::RecentProjects::popover(
-                                    workspace,
-                                    false,
-                                    focus_handle.clone(),
-                                    window,
-                                    cx,
-                                ))
-                            })
-                            .trigger_with_tooltip(
-                                IconButton::new("new-workspace", IconName::OpenFolder)
-                                    .icon_size(IconSize::Small),
-                                |_window, cx| {
-                                    Tooltip::for_action(
-                                        "Open Recent Project",
-                                        &zed_actions::OpenRecent {
-                                            create_new_window: false,
-                                        },
-                                        cx,
-                                    )
-                                },
-                            )
-                            .anchor(gpui::Corner::TopLeft)
-                    }),
-            )
-            .child(
-                h_flex()
-                    .flex_none()
-                    .p_2()
-                    .h(Tab::container_height(cx))
-                    .gap_1p5()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .child(
-                        Icon::new(IconName::MagnifyingGlass)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .child(self.render_filter_input(cx))
-                    .when(has_query, |this| {
-                        this.pr_1().child(
-                            IconButton::new("clear_filter", IconName::Close)
-                                .shape(IconButtonShape::Square)
-                                .tooltip(Tooltip::text("Clear Search"))
-                                .on_click(cx.listener(|this, _, window, cx| {
-                                    this.reset_filter_editor_text(window, cx);
-                                    this.update_entries(window, cx);
-                                })),
-                        )
-                    }),
-            )
-            .child(
-                v_flex()
-                    .flex_1()
-                    .overflow_hidden()
-                    .child(
-                        list(
-                            self.list_state.clone(),
-                            cx.processor(Self::render_list_entry),
-                        )
-                        .flex_1()
-                        .size_full(),
-                    )
-                    .vertical_scrollbar_for(&self.list_state, window, cx),
-            )
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use acp_thread::StubAgentConnection;
-    use agent::ThreadStore;
-    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
-    use assistant_text_thread::TextThreadStore;
-    use chrono::DateTime;
-    use feature_flags::FeatureFlagAppExt as _;
-    use fs::FakeFs;
-    use gpui::TestAppContext;
-    use settings::SettingsStore;
-    use std::sync::Arc;
-    use util::path_list::PathList;
-
-    fn init_test(cx: &mut TestAppContext) {
-        cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-            theme::init(theme::LoadThemes::JustBase, cx);
-            editor::init(cx);
-            cx.update_flags(false, vec!["agent-v2".into()]);
-            ThreadStore::init_global(cx);
-        });
-    }
-
-    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
-        agent::DbThread {
-            title: title.to_string().into(),
-            messages: Vec::new(),
-            updated_at,
-            detailed_summary: None,
-            initial_project_snapshot: None,
-            cumulative_token_usage: Default::default(),
-            request_token_usage: Default::default(),
-            model: None,
-            profile: None,
-            imported: false,
-            subagent_context: None,
-            speed: None,
-            thinking_enabled: false,
-            thinking_effort: None,
-            draft_prompt: None,
-            ui_scroll_position: None,
-        }
-    }
-
-    async fn init_test_project(
-        worktree_path: &str,
-        cx: &mut TestAppContext,
-    ) -> Entity<project::Project> {
-        init_test(cx);
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
-            .await;
-        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-        project::Project::test(fs, [worktree_path.as_ref()], cx).await
-    }
-
-    fn setup_sidebar(
-        multi_workspace: &Entity<MultiWorkspace>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> Entity<Sidebar> {
-        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
-            let mw_handle = cx.entity();
-            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
-        });
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.register_sidebar(sidebar.clone(), window, cx);
-        });
-        cx.run_until_parked();
-        sidebar
-    }
-
-    async fn save_n_test_threads(
-        count: u32,
-        path_list: &PathList,
-        cx: &mut gpui::VisualTestContext,
-    ) {
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-        for i in 0..count {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
-                    make_test_thread(
-                        &format!("Thread {}", i + 1),
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
-                    ),
-                    path_list.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-        cx.run_until_parked();
-    }
-
-    async fn save_thread_to_store(
-        session_id: &acp::SessionId,
-        path_list: &PathList,
-        cx: &mut gpui::VisualTestContext,
-    ) {
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                session_id.clone(),
-                make_test_thread(
-                    "Test",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        cx.run_until_parked();
-    }
-
-    fn open_and_focus_sidebar(
-        sidebar: &Entity<Sidebar>,
-        multi_workspace: &Entity<MultiWorkspace>,
-        cx: &mut gpui::VisualTestContext,
-    ) {
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.toggle_sidebar(window, cx);
-        });
-        cx.run_until_parked();
-        sidebar.update_in(cx, |_, window, cx| {
-            cx.focus_self(window);
-        });
-        cx.run_until_parked();
-    }
-
-    fn visible_entries_as_strings(
-        sidebar: &Entity<Sidebar>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> Vec<String> {
-        sidebar.read_with(cx, |sidebar, _cx| {
-            sidebar
-                .contents
-                .entries
-                .iter()
-                .enumerate()
-                .map(|(ix, entry)| {
-                    let selected = if sidebar.selection == Some(ix) {
-                        "  <== selected"
-                    } else {
-                        ""
-                    };
-                    match entry {
-                        ListEntry::ProjectHeader {
-                            label,
-                            path_list,
-                            highlight_positions: _,
-                            ..
-                        } => {
-                            let icon = if sidebar.collapsed_groups.contains(path_list) {
-                                ">"
-                            } else {
-                                "v"
-                            };
-                            format!("{} [{}]{}", icon, label, selected)
-                        }
-                        ListEntry::Thread {
-                            session_info,
-                            status,
-                            is_live,
-                            ..
-                        } => {
-                            let title = session_info
-                                .title
-                                .as_ref()
-                                .map(|s| s.as_ref())
-                                .unwrap_or("Untitled");
-                            let active = if *is_live { " *" } else { "" };
-                            let status_str = match status {
-                                AgentThreadStatus::Running => " (running)",
-                                AgentThreadStatus::Error => " (error)",
-                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
-                                _ => "",
-                            };
-                            let notified = if sidebar
-                                .contents
-                                .is_thread_notified(&session_info.session_id)
-                            {
-                                " (!)"
-                            } else {
-                                ""
-                            };
-                            format!(
-                                "  {}{}{}{}{}",
-                                title, active, status_str, notified, selected
-                            )
-                        }
-                        ListEntry::ViewMore {
-                            remaining_count, ..
-                        } => {
-                            format!("  + View More ({}){}", remaining_count, selected)
-                        }
-                        ListEntry::NewThread { .. } => {
-                            format!("  [+ New Thread]{}", selected)
-                        }
-                    }
-                })
-                .collect()
-        })
-    }
-
-    #[gpui::test]
-    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  [+ New Thread]"]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("thread-1")),
-                make_test_thread(
-                    "Fix crash in project panel",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("thread-2")),
-                make_test_thread(
-                    "Add inline diff view",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        cx.run_until_parked();
-
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Fix crash in project panel",
-                "  Add inline diff view",
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
-        let project = init_test_project("/project-a", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        // Single workspace with a thread
-        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("thread-a1")),
-                make_test_thread(
-                    "Thread A1",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        cx.run_until_parked();
-
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [project-a]", "  Thread A1"]
-        );
-
-        // Add a second workspace
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.create_workspace(window, cx);
-        });
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  Thread A1",
-                "v [Empty Workspace]",
-                "  [+ New Thread]"
-            ]
-        );
-
-        // Remove the second workspace
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.remove_workspace(1, window, cx);
-        });
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [project-a]", "  Thread A1"]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_view_more_pagination(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(12, &path_list, cx).await;
-
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Thread 12",
-                "  Thread 11",
-                "  Thread 10",
-                "  Thread 9",
-                "  Thread 8",
-                "  + View More (7)",
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(1, &path_list, cx).await;
-
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Thread 1"]
-        );
-
-        // Collapse
-        sidebar.update_in(cx, |s, window, cx| {
-            s.toggle_collapse(&path_list, window, cx);
-        });
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["> [my-project]"]
-        );
-
-        // Expand
-        sidebar.update_in(cx, |s, window, cx| {
-            s.toggle_collapse(&path_list, window, cx);
-        });
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Thread 1"]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
-        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
-
-        sidebar.update_in(cx, |s, _window, _cx| {
-            s.collapsed_groups.insert(collapsed_path.clone());
-            s.contents
-                .notified_threads
-                .insert(acp::SessionId::new(Arc::from("t-5")));
-            s.contents.entries = vec![
-                // Expanded project header
-                ListEntry::ProjectHeader {
-                    path_list: expanded_path.clone(),
-                    label: "expanded-project".into(),
-                    highlight_positions: Vec::new(),
-                },
-                // Thread with default (Completed) status, not active
-                ListEntry::Thread {
-                    session_info: acp_thread::AgentSessionInfo {
-                        session_id: acp::SessionId::new(Arc::from("t-1")),
-                        cwd: None,
-                        title: Some("Completed thread".into()),
-                        updated_at: Some(Utc::now()),
-                        meta: None,
-                    },
-                    icon: IconName::ZedAgent,
-                    icon_from_external_svg: None,
-                    status: AgentThreadStatus::Completed,
-                    diff_stats: None,
-                    workspace_index: 0,
-                    is_live: false,
-                    is_background: false,
-                    highlight_positions: Vec::new(),
-                },
-                // Active thread with Running status
-                ListEntry::Thread {
-                    session_info: acp_thread::AgentSessionInfo {
-                        session_id: acp::SessionId::new(Arc::from("t-2")),
-                        cwd: None,
-                        title: Some("Running thread".into()),
-                        updated_at: Some(Utc::now()),
-                        meta: None,
-                    },
-                    icon: IconName::ZedAgent,
-                    icon_from_external_svg: None,
-                    status: AgentThreadStatus::Running,
-                    diff_stats: None,
-                    workspace_index: 0,
-                    is_live: true,
-                    is_background: false,
-                    highlight_positions: Vec::new(),
-                },
-                // Active thread with Error status
-                ListEntry::Thread {
-                    session_info: acp_thread::AgentSessionInfo {
-                        session_id: acp::SessionId::new(Arc::from("t-3")),
-                        cwd: None,
-                        title: Some("Error thread".into()),
-                        updated_at: Some(Utc::now()),
-                        meta: None,
-                    },
-                    icon: IconName::ZedAgent,
-                    icon_from_external_svg: None,
-                    status: AgentThreadStatus::Error,
-                    diff_stats: None,
-                    workspace_index: 1,
-                    is_live: true,
-                    is_background: false,
-                    highlight_positions: Vec::new(),
-                },
-                // Thread with WaitingForConfirmation status, not active
-                ListEntry::Thread {
-                    session_info: acp_thread::AgentSessionInfo {
-                        session_id: acp::SessionId::new(Arc::from("t-4")),
-                        cwd: None,
-                        title: Some("Waiting thread".into()),
-                        updated_at: Some(Utc::now()),
-                        meta: None,
-                    },
-                    icon: IconName::ZedAgent,
-                    icon_from_external_svg: None,
-                    status: AgentThreadStatus::WaitingForConfirmation,
-                    diff_stats: None,
-                    workspace_index: 0,
-                    is_live: false,
-                    is_background: false,
-                    highlight_positions: Vec::new(),
-                },
-                // Background thread that completed (should show notification)
-                ListEntry::Thread {
-                    session_info: acp_thread::AgentSessionInfo {
-                        session_id: acp::SessionId::new(Arc::from("t-5")),
-                        cwd: None,
-                        title: Some("Notified thread".into()),
-                        updated_at: Some(Utc::now()),
-                        meta: None,
-                    },
-                    icon: IconName::ZedAgent,
-                    icon_from_external_svg: None,
-                    status: AgentThreadStatus::Completed,
-                    diff_stats: None,
-                    workspace_index: 1,
-                    is_live: true,
-                    is_background: true,
-                    highlight_positions: Vec::new(),
-                },
-                // View More entry
-                ListEntry::ViewMore {
-                    path_list: expanded_path.clone(),
-                    remaining_count: 42,
-                },
-                // Collapsed project header
-                ListEntry::ProjectHeader {
-                    path_list: collapsed_path.clone(),
-                    label: "collapsed-project".into(),
-                    highlight_positions: Vec::new(),
-                },
-            ];
-            // Select the Running thread (index 2)
-            s.selection = Some(2);
-        });
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [expanded-project]",
-                "  Completed thread",
-                "  Running thread * (running)  <== selected",
-                "  Error thread * (error)",
-                "  Waiting thread (waiting)",
-                "  Notified thread * (!)",
-                "  + View More (42)",
-                "> [collapsed-project]",
-            ]
-        );
-
-        // Move selection to the collapsed header
-        sidebar.update_in(cx, |s, _window, _cx| {
-            s.selection = Some(7);
-        });
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx).last().cloned(),
-            Some("> [collapsed-project]  <== selected".to_string()),
-        );
-
-        // Clear selection
-        sidebar.update_in(cx, |s, _window, _cx| {
-            s.selection = None;
-        });
-
-        // No entry should have the selected marker
-        let entries = visible_entries_as_strings(&sidebar, cx);
-        for entry in &entries {
-            assert!(
-                !entry.contains("<== selected"),
-                "unexpected selection marker in: {}",
-                entry
-            );
-        }
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(3, &path_list, cx).await;
-
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        // Entries: [header, thread3, thread2, thread1]
-        // Focusing the sidebar triggers focus_in, which selects the first entry
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-
-        // Move down through all entries
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
-
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
-
-        // At the end, selection stays on the last entry
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
-
-        // Move back up
-
-        cx.dispatch_action(SelectPrevious);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
-
-        cx.dispatch_action(SelectPrevious);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-
-        cx.dispatch_action(SelectPrevious);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-
-        // At the top, selection stays on the first entry
-        cx.dispatch_action(SelectPrevious);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(3, &path_list, cx).await;
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-
-        // SelectLast jumps to the end
-        cx.dispatch_action(SelectLast);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
-
-        // SelectFirst jumps to the beginning
-        cx.dispatch_action(SelectFirst);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_focus_in_selects_first(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        // Initially no selection
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
-
-        // Open the sidebar so it's rendered, then focus it to trigger focus_in
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-
-        // Blur the sidebar, then refocus — existing selection should be preserved
-        cx.update(|window, _cx| {
-            window.blur();
-        });
-        cx.run_until_parked();
-
-        sidebar.update_in(cx, |_, window, cx| {
-            cx.focus_self(window);
-        });
-        cx.run_until_parked();
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(1, &path_list, cx).await;
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Thread 1"]
-        );
-
-        // Focus the sidebar — focus_in selects the header (index 0)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-
-        // Press confirm to collapse
-        cx.dispatch_action(Confirm);
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["> [my-project]  <== selected"]
-        );
-
-        // Confirm again to expand
-        cx.dispatch_action(Confirm);
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]  <== selected", "  Thread 1",]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(8, &path_list, cx).await;
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        // Should show header + 5 threads + "View More (3)"
-        let entries = visible_entries_as_strings(&sidebar, cx);
-        assert_eq!(entries.len(), 7);
-        assert!(entries.iter().any(|e| e.contains("View More (3)")));
-
-        // Focus sidebar (selects index 0), then navigate down to the "View More" entry (index 6)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        for _ in 0..6 {
-            cx.dispatch_action(SelectNext);
-        }
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
-
-        // Confirm on "View More" to expand
-        cx.dispatch_action(Confirm);
-        cx.run_until_parked();
-
-        // All 8 threads should now be visible, no "View More"
-        let entries = visible_entries_as_strings(&sidebar, cx);
-        assert_eq!(entries.len(), 9); // header + 8 threads
-        assert!(!entries.iter().any(|e| e.contains("View More")));
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(1, &path_list, cx).await;
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Thread 1"]
-        );
-
-        // Focus sidebar — focus_in selects the header (index 0). Press left to collapse.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-
-        cx.dispatch_action(CollapseSelectedEntry);
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["> [my-project]  <== selected"]
-        );
-
-        // Press right to expand
-        cx.dispatch_action(ExpandSelectedEntry);
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]  <== selected", "  Thread 1",]
-        );
-
-        // Press right again on already-expanded header moves selection down
-        cx.dispatch_action(ExpandSelectedEntry);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(1, &path_list, cx).await;
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        // Focus sidebar (selects header at index 0), then navigate down to the thread (child)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Thread 1  <== selected",]
-        );
-
-        // Pressing left on a child collapses the parent group and selects it
-        cx.dispatch_action(CollapseSelectedEntry);
-        cx.run_until_parked();
-
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["> [my-project]  <== selected"]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
-        let project = init_test_project("/empty-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        // Even an empty project has the header and a new thread button
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [empty-project]", "  [+ New Thread]"]
-        );
-
-        // Focus sidebar — focus_in selects the first entry (header at 0)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-
-        // SelectNext moves to the new thread button
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-
-        // At the end, selection stays on the last entry
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-
-        // SelectPrevious goes back to the header
-        cx.dispatch_action(SelectPrevious);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-    }
-
-    #[gpui::test]
-    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        save_n_test_threads(1, &path_list, cx).await;
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        // Focus sidebar (selects header at 0), navigate down to the thread (index 1)
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        cx.dispatch_action(SelectNext);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
-
-        // Collapse the group, which removes the thread from the list
-        cx.dispatch_action(CollapseSelectedEntry);
-        cx.run_until_parked();
-
-        // Selection should be clamped to the last valid index (0 = header)
-        let selection = sidebar.read_with(cx, |s, _| s.selection);
-        let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len());
-        assert!(
-            selection.unwrap_or(0) < entry_count,
-            "selection {} should be within bounds (entries: {})",
-            selection.unwrap_or(0),
-            entry_count,
-        );
-    }
-
-    async fn init_test_project_with_agent_panel(
-        worktree_path: &str,
-        cx: &mut TestAppContext,
-    ) -> Entity<project::Project> {
-        agent_ui::test_support::init_test(cx);
-        cx.update(|cx| {
-            cx.update_flags(false, vec!["agent-v2".into()]);
-            ThreadStore::init_global(cx);
-            language_model::LanguageModelRegistry::test(cx);
-        });
-
-        let fs = FakeFs::new(cx.executor());
-        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
-            .await;
-        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-        project::Project::test(fs, [worktree_path.as_ref()], cx).await
-    }
-
-    fn add_agent_panel(
-        workspace: &Entity<Workspace>,
-        project: &Entity<project::Project>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> Entity<AgentPanel> {
-        workspace.update_in(cx, |workspace, window, cx| {
-            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
-            let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx));
-            workspace.add_panel(panel.clone(), window, cx);
-            panel
-        })
-    }
-
-    fn setup_sidebar_with_agent_panel(
-        multi_workspace: &Entity<MultiWorkspace>,
-        project: &Entity<project::Project>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
-        let sidebar = setup_sidebar(multi_workspace, cx);
-        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
-        let panel = add_agent_panel(&workspace, project, cx);
-        (sidebar, panel)
-    }
-
-    #[gpui::test]
-    async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
-        let project = init_test_project_with_agent_panel("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
-        // Open thread A and keep it generating.
-        let connection_a = StubAgentConnection::new();
-        open_thread_with_connection(&panel, connection_a.clone(), cx);
-        send_message(&panel, cx);
-
-        let session_id_a = active_session_id(&panel, cx);
-        save_thread_to_store(&session_id_a, &path_list, cx).await;
-
-        cx.update(|_, cx| {
-            connection_a.send_update(
-                session_id_a.clone(),
-                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
-                cx,
-            );
-        });
-        cx.run_until_parked();
-
-        // Open thread B (idle, default response) — thread A goes to background.
-        let connection_b = StubAgentConnection::new();
-        connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
-            acp::ContentChunk::new("Done".into()),
-        )]);
-        open_thread_with_connection(&panel, connection_b, cx);
-        send_message(&panel, cx);
-
-        let session_id_b = active_session_id(&panel, cx);
-        save_thread_to_store(&session_id_b, &path_list, cx).await;
-
-        cx.run_until_parked();
-
-        let mut entries = visible_entries_as_strings(&sidebar, cx);
-        entries[1..].sort();
-        assert_eq!(
-            entries,
-            vec!["v [my-project]", "  Hello *", "  Hello * (running)",]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
-        let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
-        let (multi_workspace, cx) = cx
-            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
-        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
-
-        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
-        // Open thread on workspace A and keep it generating.
-        let connection_a = StubAgentConnection::new();
-        open_thread_with_connection(&panel_a, connection_a.clone(), cx);
-        send_message(&panel_a, cx);
-
-        let session_id_a = active_session_id(&panel_a, cx);
-        save_thread_to_store(&session_id_a, &path_list_a, cx).await;
-
-        cx.update(|_, cx| {
-            connection_a.send_update(
-                session_id_a.clone(),
-                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
-                cx,
-            );
-        });
-        cx.run_until_parked();
-
-        // Add a second workspace and activate it (making workspace A the background).
-        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
-        let project_b = project::Project::test(fs, [], cx).await;
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.test_add_workspace(project_b, window, cx);
-        });
-        cx.run_until_parked();
-
-        // Thread A is still running; no notification yet.
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  Hello * (running)",
-                "v [Empty Workspace]",
-                "  [+ New Thread]",
-            ]
-        );
-
-        // Complete thread A's turn (transition Running → Completed).
-        connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn);
-        cx.run_until_parked();
-
-        // The completed background thread shows a notification indicator.
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  Hello * (!)",
-                "v [Empty Workspace]",
-                "  [+ New Thread]",
-            ]
-        );
-    }
-
-    fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualTestContext) {
-        sidebar.update_in(cx, |sidebar, window, cx| {
-            window.focus(&sidebar.filter_editor.focus_handle(cx), cx);
-            sidebar.filter_editor.update(cx, |editor, cx| {
-                editor.set_text(query, window, cx);
-            });
-        });
-        cx.run_until_parked();
-    }
-
-    #[gpui::test]
-    async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        for (id, title, hour) in [
-            ("t-1", "Fix crash in project panel", 3),
-            ("t-2", "Add inline diff view", 2),
-            ("t-3", "Refactor settings module", 1),
-        ] {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(id)),
-                    make_test_thread(
-                        title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
-                    ),
-                    path_list.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Fix crash in project panel",
-                "  Add inline diff view",
-                "  Refactor settings module",
-            ]
-        );
-
-        // User types "diff" in the search box — only the matching thread remains,
-        // with its workspace header preserved for context.
-        type_in_search(&sidebar, "diff", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Add inline diff view  <== selected",]
-        );
-
-        // User changes query to something with no matches — list is empty.
-        type_in_search(&sidebar, "nonexistent", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            Vec::<String>::new()
-        );
-    }
-
-    #[gpui::test]
-    async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
-        // Scenario: A user remembers a thread title but not the exact casing.
-        // Search should match case-insensitively so they can still find it.
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("thread-1")),
-                make_test_thread(
-                    "Fix Crash In Project Panel",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        cx.run_until_parked();
-
-        // Lowercase query matches mixed-case title.
-        type_in_search(&sidebar, "fix crash", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Fix Crash In Project Panel  <== selected",
-            ]
-        );
-
-        // Uppercase query also matches the same title.
-        type_in_search(&sidebar, "FIX CRASH", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Fix Crash In Project Panel  <== selected",
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) {
-        // Scenario: A user searches, finds what they need, then presses Escape
-        // to dismiss the filter and see the full list again.
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(id)),
-                    make_test_thread(
-                        title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
-                    ),
-                    path_list.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-        cx.run_until_parked();
-
-        // Confirm the full list is showing.
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Alpha thread", "  Beta thread",]
-        );
-
-        // User types a search query to filter down.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        type_in_search(&sidebar, "alpha", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Alpha thread  <== selected",]
-        );
-
-        // User presses Escape — filter clears, full list is restored.
-        cx.dispatch_action(Cancel);
-        cx.run_until_parked();
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Alpha thread  <== selected",
-                "  Beta thread",
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
-        let project_a = init_test_project("/project-a", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        for (id, title, hour) in [
-            ("a1", "Fix bug in sidebar", 2),
-            ("a2", "Add tests for editor", 1),
-        ] {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(id)),
-                    make_test_thread(
-                        title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
-                    ),
-                    path_list_a.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-
-        // Add a second workspace.
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.create_workspace(window, cx);
-        });
-        cx.run_until_parked();
-
-        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
-
-        for (id, title, hour) in [
-            ("b1", "Refactor sidebar layout", 3),
-            ("b2", "Fix typo in README", 1),
-        ] {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(id)),
-                    make_test_thread(
-                        title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
-                    ),
-                    path_list_b.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  Fix bug in sidebar",
-                "  Add tests for editor",
-                "v [Empty Workspace]",
-                "  Refactor sidebar layout",
-                "  Fix typo in README",
-            ]
-        );
-
-        // "sidebar" matches a thread in each workspace — both headers stay visible.
-        type_in_search(&sidebar, "sidebar", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [project-a]",
-                "  Fix bug in sidebar  <== selected",
-                "v [Empty Workspace]",
-                "  Refactor sidebar layout",
-            ]
-        );
-
-        // "typo" only matches in the second workspace — the first header disappears.
-        type_in_search(&sidebar, "typo", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [Empty Workspace]", "  Fix typo in README  <== selected",]
-        );
-
-        // "project-a" matches the first workspace name — the header appears alone
-        // without any child threads (none of them match "project-a").
-        type_in_search(&sidebar, "project-a", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [project-a]  <== selected"]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
-        let project_a = init_test_project("/alpha-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        for (id, title, hour) in [
-            ("a1", "Fix bug in sidebar", 2),
-            ("a2", "Add tests for editor", 1),
-        ] {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(id)),
-                    make_test_thread(
-                        title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
-                    ),
-                    path_list_a.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-
-        // Add a second workspace.
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.create_workspace(window, cx);
-        });
-        cx.run_until_parked();
-
-        let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
-
-        for (id, title, hour) in [
-            ("b1", "Refactor sidebar layout", 3),
-            ("b2", "Fix typo in README", 1),
-        ] {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(id)),
-                    make_test_thread(
-                        title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
-                    ),
-                    path_list_b.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-        cx.run_until_parked();
-
-        // "alpha" matches the workspace name "alpha-project" but no thread titles.
-        // The workspace header should appear with no child threads.
-        type_in_search(&sidebar, "alpha", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [alpha-project]  <== selected"]
-        );
-
-        // "sidebar" matches thread titles in both workspaces but not workspace names.
-        // Both headers appear with their matching threads.
-        type_in_search(&sidebar, "sidebar", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [alpha-project]",
-                "  Fix bug in sidebar  <== selected",
-                "v [Empty Workspace]",
-                "  Refactor sidebar layout",
-            ]
-        );
-
-        // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r
-        // doesn't match) — but does not match either workspace name or any thread.
-        // Actually let's test something simpler: a query that matches both a workspace
-        // name AND some threads in that workspace. Matching threads should still appear.
-        type_in_search(&sidebar, "fix", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [alpha-project]",
-                "  Fix bug in sidebar  <== selected",
-                "v [Empty Workspace]",
-                "  Fix typo in README",
-            ]
-        );
-
-        // A query that matches a workspace name AND a thread in that same workspace.
-        // Both the header (highlighted) and the matching thread should appear.
-        type_in_search(&sidebar, "alpha", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [alpha-project]  <== selected"]
-        );
-
-        // Now search for something that matches only a workspace name when there
-        // are also threads with matching titles — the non-matching workspace's
-        // threads should still appear if their titles match.
-        type_in_search(&sidebar, "alp", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [alpha-project]  <== selected"]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        // Create 8 threads. The oldest one has a unique name and will be
-        // behind View More (only 5 shown by default).
-        for i in 0..8u32 {
-            let title = if i == 0 {
-                "Hidden gem thread".to_string()
-            } else {
-                format!("Thread {}", i + 1)
-            };
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
-                    make_test_thread(
-                        &title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
-                    ),
-                    path_list.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-        cx.run_until_parked();
-
-        // Confirm the thread is not visible and View More is shown.
-        let entries = visible_entries_as_strings(&sidebar, cx);
-        assert!(
-            entries.iter().any(|e| e.contains("View More")),
-            "should have View More button"
-        );
-        assert!(
-            !entries.iter().any(|e| e.contains("Hidden gem")),
-            "Hidden gem should be behind View More"
-        );
-
-        // User searches for the hidden thread — it appears, and View More is gone.
-        type_in_search(&sidebar, "hidden gem", cx);
-        let filtered = visible_entries_as_strings(&sidebar, cx);
-        assert_eq!(
-            filtered,
-            vec!["v [my-project]", "  Hidden gem thread  <== selected",]
-        );
-        assert!(
-            !filtered.iter().any(|e| e.contains("View More")),
-            "View More should not appear when filtering"
-        );
-    }
-
-    #[gpui::test]
-    async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("thread-1")),
-                make_test_thread(
-                    "Important thread",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        cx.run_until_parked();
-
-        // User focuses the sidebar and collapses the group using keyboard:
-        // select the header, then press Confirm to toggle collapse.
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
-        cx.dispatch_action(Confirm);
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["> [my-project]  <== selected"]
-        );
-
-        // User types a search — the thread appears even though its group is collapsed.
-        type_in_search(&sidebar, "important", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["> [my-project]", "  Important thread  <== selected",]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        for (id, title, hour) in [
-            ("t-1", "Fix crash in panel", 3),
-            ("t-2", "Fix lint warnings", 2),
-            ("t-3", "Add new feature", 1),
-        ] {
-            let save_task = thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new(Arc::from(id)),
-                    make_test_thread(
-                        title,
-                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
-                    ),
-                    path_list.clone(),
-                    cx,
-                )
-            });
-            save_task.await.unwrap();
-        }
-        cx.run_until_parked();
-
-        open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
-
-        // User types "fix" — two threads match.
-        type_in_search(&sidebar, "fix", cx);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Fix crash in panel  <== selected",
-                "  Fix lint warnings",
-            ]
-        );
-
-        // Selection starts on the first matching thread. User presses
-        // SelectNext to move to the second match.
-        cx.dispatch_action(SelectNext);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Fix crash in panel",
-                "  Fix lint warnings  <== selected",
-            ]
-        );
-
-        // User can also jump back with SelectPrevious.
-        cx.dispatch_action(SelectPrevious);
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Fix crash in panel  <== selected",
-                "  Fix lint warnings",
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.create_workspace(window, cx);
-        });
-        cx.run_until_parked();
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("hist-1")),
-                make_test_thread(
-                    "Historical Thread",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        cx.run_until_parked();
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec![
-                "v [my-project]",
-                "  Historical Thread",
-                "v [Empty Workspace]",
-                "  [+ New Thread]",
-            ]
-        );
-
-        // Switch to workspace 1 so we can verify the confirm switches back.
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.activate_index(1, window, cx);
-        });
-        cx.run_until_parked();
-        assert_eq!(
-            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
-            1
-        );
-
-        // Confirm on the historical (non-live) thread at index 1.
-        // Before the fix, workspace_index was Option<usize> and historical
-        // threads had None, so activate_thread early-returned without
-        // switching the workspace.
-        sidebar.update_in(cx, |sidebar, window, cx| {
-            sidebar.selection = Some(1);
-            sidebar.confirm(&Confirm, window, cx);
-        });
-        cx.run_until_parked();
-
-        assert_eq!(
-            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
-            0
-        );
-    }
-
-    #[gpui::test]
-    async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-        let sidebar = setup_sidebar(&multi_workspace, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
-
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("t-1")),
-                make_test_thread(
-                    "Thread A",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        let save_task = thread_store.update(cx, |store, cx| {
-            store.save_thread(
-                acp::SessionId::new(Arc::from("t-2")),
-                make_test_thread(
-                    "Thread B",
-                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
-                ),
-                path_list.clone(),
-                cx,
-            )
-        });
-        save_task.await.unwrap();
-        cx.run_until_parked();
-        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Thread A", "  Thread B",]
-        );
-
-        // Keyboard confirm preserves selection.
-        sidebar.update_in(cx, |sidebar, window, cx| {
-            sidebar.selection = Some(1);
-            sidebar.confirm(&Confirm, window, cx);
-        });
-        assert_eq!(
-            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
-            Some(1)
-        );
-
-        // Click handlers clear selection to None so no highlight lingers
-        // after a click regardless of focus state. The hover style provides
-        // visual feedback during mouse interaction instead.
-        sidebar.update_in(cx, |sidebar, window, cx| {
-            sidebar.selection = None;
-            let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-            sidebar.toggle_collapse(&path_list, window, cx);
-        });
-        assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None);
-
-        // When the user tabs back into the sidebar, focus_in restores
-        // selection to the first entry for keyboard navigation.
-        sidebar.update_in(cx, |sidebar, window, cx| {
-            sidebar.focus_in(window, cx);
-        });
-        assert_eq!(
-            sidebar.read_with(cx, |sidebar, _| sidebar.selection),
-            Some(0)
-        );
-    }
-
-    #[gpui::test]
-    async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
-        let project = init_test_project_with_agent_panel("/my-project", cx).await;
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
-
-        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
-        let connection = StubAgentConnection::new();
-        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
-            acp::ContentChunk::new("Hi there!".into()),
-        )]);
-        open_thread_with_connection(&panel, connection, cx);
-        send_message(&panel, cx);
-
-        let session_id = active_session_id(&panel, cx);
-        save_thread_to_store(&session_id, &path_list, cx).await;
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Hello *"]
-        );
-
-        // Simulate the agent generating a title. The notification chain is:
-        // AcpThread::set_title emits TitleUpdated →
-        // ConnectionView::handle_thread_event calls cx.notify() →
-        // AgentPanel observer fires and emits AgentPanelEvent →
-        // Sidebar subscription calls update_entries / rebuild_contents.
-        //
-        // Before the fix, handle_thread_event did NOT call cx.notify() for
-        // TitleUpdated, so the AgentPanel observer never fired and the
-        // sidebar kept showing the old title.
-        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
-        thread.update(cx, |thread, cx| {
-            thread
-                .set_title("Friendly Greeting with AI".into(), cx)
-                .detach();
-        });
-        cx.run_until_parked();
-
-        assert_eq!(
-            visible_entries_as_strings(&sidebar, cx),
-            vec!["v [my-project]", "  Friendly Greeting with AI *"]
-        );
-    }
-}

crates/sqlez/src/connection.rs 🔗

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

crates/sqlez/src/thread_safe_connection.rs 🔗

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

crates/sum_tree/Cargo.toml 🔗

@@ -19,11 +19,17 @@ rayon.workspace = true
 log.workspace = true
 ztracing.workspace = true
 tracing.workspace = true
+proptest = { workspace = true, optional = true }
 
 [dev-dependencies]
 ctor.workspace = true
 rand.workspace = true
+proptest.workspace = true
 zlog.workspace = true
 
+
 [package.metadata.cargo-machete]
 ignored = ["tracing"]
+
+[features]
+test-support = ["proptest"]

crates/sum_tree/src/property_test.rs 🔗

@@ -0,0 +1,32 @@
+use core::fmt::Debug;
+
+use proptest::{prelude::*, sample::SizeRange};
+
+use crate::{Item, SumTree, Summary};
+
+impl<T> Arbitrary for SumTree<T>
+where
+    T: Debug + Arbitrary + Item + 'static,
+    T::Summary: Debug + Summary<Context<'static> = ()>,
+{
+    type Parameters = ();
+    type Strategy = BoxedStrategy<Self>;
+
+    fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
+        any::<Vec<T>>()
+            .prop_map(|vec| SumTree::from_iter(vec, ()))
+            .boxed()
+    }
+}
+
+/// A strategy for producing a [`SumTree`] with a given size.
+///
+/// Equivalent to [`proptest::collection::vec`].
+pub fn sum_tree<S, T>(values: S, size: impl Into<SizeRange>) -> impl Strategy<Value = SumTree<T>>
+where
+    T: Debug + Arbitrary + Item + 'static,
+    T::Summary: Debug + Summary<Context<'static> = ()>,
+    S: Strategy<Value = T>,
+{
+    proptest::collection::vec(values, size).prop_map(|vec| SumTree::from_iter(vec, ()))
+}

crates/svg_preview/src/svg_preview_view.rs 🔗

@@ -182,7 +182,7 @@ impl SvgPreviewView {
             buffer,
             window,
             move |this, _buffer, event: &BufferEvent, window, cx| match event {
-                BufferEvent::Edited | BufferEvent::Saved => {
+                BufferEvent::Edited { .. } | BufferEvent::Saved => {
                     this.render_image(window, cx);
                 }
                 _ => {}

crates/tab_switcher/Cargo.toml 🔗

@@ -29,10 +29,8 @@ util.workspace = true
 workspace.workspace = true
 
 [dev-dependencies]
-anyhow.workspace = true
 ctor.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
-language = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }

crates/task/src/task_template.rs 🔗

@@ -114,6 +114,7 @@ pub enum HideStrategy {
 pub struct TaskTemplates(pub Vec<TaskTemplate>);
 
 impl TaskTemplates {
+    pub const FILE_NAME: &str = "tasks.json";
     /// Generates JSON schema of Tasks JSON template format.
     pub fn generate_json_schema() -> serde_json::Value {
         let schema = schemars::generate::SchemaSettings::draft2019_09()

crates/tasks_ui/src/tasks_ui.rs 🔗

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

crates/terminal/Cargo.toml 🔗

@@ -49,6 +49,5 @@ windows.workspace = true
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }
 rand.workspace = true
-serde_json.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 util_macros.workspace = true

crates/terminal/src/terminal.rs 🔗

@@ -415,6 +415,8 @@ impl TerminalBuilder {
             event_loop_task: Task::ready(Ok(())),
             background_executor: background_executor.clone(),
             path_style,
+            #[cfg(any(test, feature = "test-support"))]
+            input_log: Vec::new(),
         };
 
         Ok(TerminalBuilder {
@@ -646,6 +648,8 @@ impl TerminalBuilder {
                 event_loop_task: Task::ready(Ok(())),
                 background_executor,
                 path_style,
+                #[cfg(any(test, feature = "test-support"))]
+                input_log: Vec::new(),
             };
 
             if !activation_script.is_empty() && no_task {
@@ -870,6 +874,8 @@ pub struct Terminal {
     event_loop_task: Task<Result<(), anyhow::Error>>,
     background_executor: BackgroundExecutor,
     path_style: PathStyle,
+    #[cfg(any(test, feature = "test-support"))]
+    input_log: Vec<Vec<u8>>,
 }
 
 struct CopyTemplate {
@@ -1451,9 +1457,18 @@ impl Terminal {
             .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
         self.events.push_back(InternalEvent::SetSelection(None));
 
+        let input = input.into();
+        #[cfg(any(test, feature = "test-support"))]
+        self.input_log.push(input.to_vec());
+
         self.write_to_pty(input);
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn take_input_log(&mut self) -> Vec<Vec<u8>> {
+        std::mem::take(&mut self.input_log)
+    }
+
     pub fn toggle_vi_mode(&mut self) {
         self.events.push_back(InternalEvent::ToggleViMode);
     }

crates/terminal_view/Cargo.toml 🔗

@@ -48,11 +48,10 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
-client = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
-rand.workspace = true
+terminal = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
 
 [package.metadata.cargo-machete]

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -1,4 +1,4 @@
-use std::{cmp, ops::ControlFlow, path::PathBuf, process::ExitStatus, sync::Arc, time::Duration};
+use std::{cmp, path::PathBuf, process::ExitStatus, sync::Arc, time::Duration};
 
 use crate::{
     TerminalView, default_working_directory,
@@ -12,11 +12,11 @@ use db::kvp::KEY_VALUE_STORE;
 use futures::{channel::oneshot, future::join_all};
 use gpui::{
     Action, AnyView, App, AsyncApp, AsyncWindowContext, Context, Corner, Entity, EventEmitter,
-    ExternalPaths, FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled,
-    Task, WeakEntity, Window, actions,
+    FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, WeakEntity,
+    Window, actions,
 };
 use itertools::Itertools;
-use project::{Fs, Project, ProjectEntryId};
+use project::{Fs, Project};
 
 use settings::{Settings, TerminalDockPosition};
 use task::{RevealStrategy, RevealTarget, Shell, ShellBuilder, SpawnInTerminal, TaskId};
@@ -28,13 +28,13 @@ use ui::{
 use util::{ResultExt, TryFutureExt};
 use workspace::{
     ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight,
-    ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane,
+    ActivatePaneUp, ActivatePreviousPane, DraggedTab, ItemId, MoveItemToPane,
     MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, Pane,
     PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp, SwapPaneDown,
     SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace,
     dock::{DockPosition, Panel, PanelEvent, PanelHandle},
     item::SerializableItem,
-    move_active_item, move_item, pane,
+    move_active_item, pane,
 };
 
 use anyhow::{Result, anyhow};
@@ -133,7 +133,11 @@ impl TerminalPanel {
         }
     }
 
-    fn apply_tab_bar_buttons(&self, terminal_pane: &Entity<Pane>, cx: &mut Context<Self>) {
+    pub(crate) fn apply_tab_bar_buttons(
+        &self,
+        terminal_pane: &Entity<Pane>,
+        cx: &mut Context<Self>,
+    ) {
         let assistant_tab_bar_button = self.assistant_tab_bar_button.clone();
         terminal_pane.update(cx, |pane, cx| {
             pane.set_render_tab_bar_buttons(cx, move |pane, window, cx| {
@@ -1187,7 +1191,6 @@ pub fn new_terminal_pane(
     window: &mut Window,
     cx: &mut Context<TerminalPanel>,
 ) -> Entity<Pane> {
-    let is_local = project.read(cx).is_local();
     let terminal_panel = cx.entity();
     let pane = cx.new(|cx| {
         let mut pane = Pane::new(
@@ -1245,113 +1248,6 @@ pub fn new_terminal_pane(
             toolbar.add_item(breadcrumbs, window, cx);
         });
 
-        let drop_closure_project = project.downgrade();
-        let drop_closure_terminal_panel = terminal_panel.downgrade();
-        pane.set_custom_drop_handle(cx, move |pane, dropped_item, window, cx| {
-            let Some(project) = drop_closure_project.upgrade() else {
-                return ControlFlow::Break(());
-            };
-            if let Some(tab) = dropped_item.downcast_ref::<DraggedTab>() {
-                let this_pane = cx.entity();
-                let item = if tab.pane == this_pane {
-                    pane.item_for_index(tab.ix)
-                } else {
-                    tab.pane.read(cx).item_for_index(tab.ix)
-                };
-                if let Some(item) = item {
-                    if item.downcast::<TerminalView>().is_some() {
-                        let source = tab.pane.clone();
-                        let item_id_to_move = item.item_id();
-
-                        // If no split direction, let the regular pane drop handler take care of it
-                        let Some(split_direction) = pane.drag_split_direction() else {
-                            return ControlFlow::Continue(());
-                        };
-
-                        // Gather data synchronously before deferring
-                        let is_zoomed = drop_closure_terminal_panel
-                            .upgrade()
-                            .map(|terminal_panel| {
-                                let terminal_panel = terminal_panel.read(cx);
-                                if terminal_panel.active_pane == this_pane {
-                                    pane.is_zoomed()
-                                } else {
-                                    terminal_panel.active_pane.read(cx).is_zoomed()
-                                }
-                            })
-                            .unwrap_or(false);
-
-                        let workspace = workspace.clone();
-                        let terminal_panel = drop_closure_terminal_panel.clone();
-
-                        // Defer the split operation to avoid re-entrancy panic.
-                        // The pane may be the one currently being updated, so we cannot
-                        // call mark_positions (via split) synchronously.
-                        cx.spawn_in(window, async move |_, cx| {
-                            cx.update(|window, cx| {
-                                let Ok(new_pane) =
-                                    terminal_panel.update(cx, |terminal_panel, cx| {
-                                        let new_pane = new_terminal_pane(
-                                            workspace, project, is_zoomed, window, cx,
-                                        );
-                                        terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
-                                        terminal_panel.center.split(
-                                            &this_pane,
-                                            &new_pane,
-                                            split_direction,
-                                            cx,
-                                        );
-                                        new_pane
-                                    })
-                                else {
-                                    return;
-                                };
-
-                                move_item(
-                                    &source,
-                                    &new_pane,
-                                    item_id_to_move,
-                                    new_pane.read(cx).active_item_index(),
-                                    true,
-                                    window,
-                                    cx,
-                                );
-                            })
-                            .ok();
-                        })
-                        .detach();
-                    } else if let Some(project_path) = item.project_path(cx)
-                        && let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx)
-                    {
-                        add_paths_to_terminal(pane, &[entry_path], window, cx);
-                    }
-                }
-            } else if let Some(selection) = dropped_item.downcast_ref::<DraggedSelection>() {
-                let project = project.read(cx);
-                let paths_to_add = selection
-                    .items()
-                    .map(|selected_entry| selected_entry.entry_id)
-                    .filter_map(|entry_id| project.path_for_entry(entry_id, cx))
-                    .filter_map(|project_path| project.absolute_path(&project_path, cx))
-                    .collect::<Vec<_>>();
-                if !paths_to_add.is_empty() {
-                    add_paths_to_terminal(pane, &paths_to_add, window, cx);
-                }
-            } else if let Some(&entry_id) = dropped_item.downcast_ref::<ProjectEntryId>() {
-                if let Some(entry_path) = project
-                    .read(cx)
-                    .path_for_entry(entry_id, cx)
-                    .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx))
-                {
-                    add_paths_to_terminal(pane, &[entry_path], window, cx);
-                }
-            } else if is_local && let Some(paths) = dropped_item.downcast_ref::<ExternalPaths>() {
-                add_paths_to_terminal(pane, paths.paths(), window, cx);
-            }
-
-            ControlFlow::Break(())
-        });
-
         pane
     });
 
@@ -1376,27 +1272,6 @@ async fn wait_for_terminals_tasks(
     join_all(pending_tasks).await;
 }
 
-fn add_paths_to_terminal(
-    pane: &mut Pane,
-    paths: &[PathBuf],
-    window: &mut Window,
-    cx: &mut Context<Pane>,
-) {
-    if let Some(terminal_view) = pane
-        .active_item()
-        .and_then(|item| item.downcast::<TerminalView>())
-    {
-        window.focus(&terminal_view.focus_handle(cx), cx);
-        let mut new_text = paths.iter().map(|path| format!(" {path:?}")).join("");
-        new_text.push(' ');
-        terminal_view.update(cx, |terminal_view, cx| {
-            terminal_view.terminal().update(cx, |terminal, _| {
-                terminal.paste(&new_text);
-            });
-        });
-    }
-}
-
 struct FailedToSpawnTerminal {
     error: String,
     focus_handle: FocusHandle,

crates/terminal_view/src/terminal_view.rs 🔗

@@ -8,18 +8,20 @@ mod terminal_slash_command;
 use assistant_slash_command::SlashCommandRegistry;
 use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
 use gpui::{
-    Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
-    Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point,
-    Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred,
-    div,
+    Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths,
+    FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent,
+    Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions,
+    anchored, deferred, div,
 };
+use itertools::Itertools;
 use menu;
 use persistence::TERMINAL_DB;
-use project::{Project, search::SearchQuery};
+use project::{Project, ProjectEntryId, search::SearchQuery};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
 use std::{
+    any::Any,
     cmp,
     ops::{Range, RangeInclusive},
     path::{Path, PathBuf},
@@ -50,10 +52,10 @@ use ui::{
 };
 use util::ResultExt;
 use workspace::{
-    CloseActiveItem, NewCenterTerminal, NewTerminal, ToolbarItemLocation, Workspace, WorkspaceId,
-    delete_unloaded_items,
+    CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane,
+    ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
     item::{
-        BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
+        HighlightedText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
     },
     register_serializable_item,
     searchable::{
@@ -833,6 +835,15 @@ impl TerminalView {
         });
     }
 
+    fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
+        let mut text = paths.iter().map(|path| format!(" {path:?}")).join("");
+        text.push(' ');
+        window.focus(&self.focus_handle(cx), cx);
+        self.terminal.update(cx, |terminal, _| {
+            terminal.paste(&text);
+        });
+    }
+
     fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
         self.clear_bell(cx);
         self.terminal.update(cx, |term, _| {
@@ -1412,6 +1423,154 @@ impl Item for TerminalView {
         None
     }
 
+    fn handle_drop(
+        &self,
+        active_pane: &Pane,
+        dropped: &dyn Any,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> bool {
+        let Some(project) = self.project.upgrade() else {
+            return false;
+        };
+
+        if let Some(paths) = dropped.downcast_ref::<ExternalPaths>() {
+            let is_local = project.read(cx).is_local();
+            if is_local {
+                self.add_paths_to_terminal(paths.paths(), window, cx);
+                return true;
+            }
+
+            return false;
+        } else if let Some(tab) = dropped.downcast_ref::<DraggedTab>() {
+            let Some(self_handle) = self.self_handle.upgrade() else {
+                return false;
+            };
+
+            let Some(workspace) = self.workspace.upgrade() else {
+                return false;
+            };
+
+            let Some(this_pane) = workspace.read(cx).pane_for(&self_handle) else {
+                return false;
+            };
+
+            let item = if tab.pane == this_pane {
+                active_pane.item_for_index(tab.ix)
+            } else {
+                tab.pane.read(cx).item_for_index(tab.ix)
+            };
+
+            let Some(item) = item else {
+                return false;
+            };
+
+            if item.downcast::<TerminalView>().is_some() {
+                let Some(split_direction) = active_pane.drag_split_direction() else {
+                    return false;
+                };
+
+                let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
+                    return false;
+                };
+
+                if !terminal_panel.read(cx).center.panes().contains(&&this_pane) {
+                    return false;
+                }
+
+                let source = tab.pane.clone();
+                let item_id_to_move = item.item_id();
+                let is_zoomed = {
+                    let terminal_panel = terminal_panel.read(cx);
+                    if terminal_panel.active_pane == this_pane {
+                        active_pane.is_zoomed()
+                    } else {
+                        terminal_panel.active_pane.read(cx).is_zoomed()
+                    }
+                };
+
+                let workspace = workspace.downgrade();
+                let terminal_panel = terminal_panel.downgrade();
+                // Defer the split operation to avoid re-entrancy panic.
+                // The pane may be the one currently being updated, so we cannot
+                // call mark_positions (via split) synchronously.
+                window
+                    .spawn(cx, async move |cx| {
+                        cx.update(|window, cx| {
+                            let Ok(new_pane) = terminal_panel.update(cx, |terminal_panel, cx| {
+                                let new_pane = terminal_panel::new_terminal_pane(
+                                    workspace, project, is_zoomed, window, cx,
+                                );
+                                terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
+                                terminal_panel.center.split(
+                                    &this_pane,
+                                    &new_pane,
+                                    split_direction,
+                                    cx,
+                                );
+                                anyhow::Ok(new_pane)
+                            }) else {
+                                return;
+                            };
+
+                            let Some(new_pane) = new_pane.log_err() else {
+                                return;
+                            };
+
+                            workspace::move_item(
+                                &source,
+                                &new_pane,
+                                item_id_to_move,
+                                new_pane.read(cx).active_item_index(),
+                                true,
+                                window,
+                                cx,
+                            );
+                        })
+                        .ok();
+                    })
+                    .detach();
+
+                return true;
+            } else {
+                if let Some(project_path) = item.project_path(cx)
+                    && let Some(path) = project.read(cx).absolute_path(&project_path, cx)
+                {
+                    self.add_paths_to_terminal(&[path], window, cx);
+                    return true;
+                }
+            }
+
+            return false;
+        } else if let Some(selection) = dropped.downcast_ref::<DraggedSelection>() {
+            let project = project.read(cx);
+            let paths = selection
+                .items()
+                .map(|selected_entry| selected_entry.entry_id)
+                .filter_map(|entry_id| project.path_for_entry(entry_id, cx))
+                .filter_map(|project_path| project.absolute_path(&project_path, cx))
+                .collect::<Vec<_>>();
+
+            if !paths.is_empty() {
+                self.add_paths_to_terminal(&paths, window, cx);
+            }
+
+            return true;
+        } else if let Some(&entry_id) = dropped.downcast_ref::<ProjectEntryId>() {
+            let project = project.read(cx);
+            if let Some(path) = project
+                .path_for_entry(entry_id, cx)
+                .and_then(|project_path| project.absolute_path(&project_path, cx))
+            {
+                self.add_paths_to_terminal(&[path], window, cx);
+            }
+
+            return true;
+        }
+
+        false
+    }
+
     fn tab_extra_context_menu_actions(
         &self,
         _window: &mut Window,
@@ -1496,12 +1655,14 @@ impl Item for TerminalView {
         }
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        Some(vec![BreadcrumbText {
-            text: self.terminal().read(cx).breadcrumb_text.clone(),
-            highlights: None,
-            font: None,
-        }])
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
+        Some((
+            vec![HighlightedText {
+                text: self.terminal().read(cx).breadcrumb_text.clone().into(),
+                highlights: vec![],
+            }],
+            None,
+        ))
     }
 
     fn added_to_workspace(
@@ -1840,10 +2001,46 @@ mod tests {
     use super::*;
     use gpui::TestAppContext;
     use project::{Entry, Project, ProjectPath, Worktree};
-    use std::path::Path;
+    use std::path::{Path, PathBuf};
     use util::paths::PathStyle;
     use util::rel_path::RelPath;
-    use workspace::{AppState, MultiWorkspace};
+    use workspace::item::test::{TestItem, TestProjectItem};
+    use workspace::{AppState, MultiWorkspace, SelectedEntry};
+
+    fn expected_drop_text(paths: &[PathBuf]) -> String {
+        let mut text = String::new();
+        for path in paths {
+            text.push(' ');
+            text.push_str(&format!("{path:?}"));
+        }
+        text.push(' ');
+        text
+    }
+
+    fn assert_drop_writes_to_terminal(
+        pane: &Entity<Pane>,
+        terminal_view_index: usize,
+        terminal: &Entity<Terminal>,
+        dropped: &dyn Any,
+        expected_text: &str,
+        window: &mut Window,
+        cx: &mut Context<MultiWorkspace>,
+    ) {
+        let _ = terminal.update(cx, |terminal, _| terminal.take_input_log());
+
+        let handled = pane.update(cx, |pane, cx| {
+            pane.item_for_index(terminal_view_index)
+                .unwrap()
+                .handle_drop(pane, dropped, window, cx)
+        });
+        assert!(handled, "handle_drop should return true for {:?}", dropped);
+
+        let mut input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
+        assert_eq!(input_log.len(), 1, "expected exactly one write to terminal");
+        let written =
+            String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8");
+        assert_eq!(written, expected_text);
+    }
 
     // Working directory calculation tests
 
@@ -1972,24 +2169,7 @@ mod tests {
         let (project, _workspace) = init_test(cx).await;
 
         let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
-        let entry = cx
-            .update(|cx| {
-                wt.update(cx, |wt, cx| {
-                    wt.create_entry(
-                        RelPath::new(Path::new("src/main.rs"), PathStyle::local())
-                            .unwrap()
-                            .as_ref()
-                            .into(),
-                        false,
-                        None,
-                        cx,
-                    )
-                })
-            })
-            .await
-            .unwrap()
-            .into_included()
-            .unwrap();
+        let entry = create_file_in_worktree(wt.clone(), "src/main.rs", cx).await;
         insert_active_entry_for(wt, entry, project.clone(), cx);
 
         cx.update(|cx| {
@@ -2014,6 +2194,18 @@ mod tests {
 
     /// Creates a worktree with 1 file: /root.txt
     pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
+        let (project, workspace, _) = init_test_with_window(cx).await;
+        (project, workspace)
+    }
+
+    /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle.
+    async fn init_test_with_window(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<Project>,
+        Entity<Workspace>,
+        gpui::WindowHandle<MultiWorkspace>,
+    ) {
         let params = cx.update(AppState::test);
         cx.update(|cx| {
             theme::init(theme::LoadThemes::JustBase, cx);
@@ -2026,7 +2218,32 @@ mod tests {
             .read_with(cx, |mw, _| mw.workspace().clone())
             .unwrap();
 
-        (project, workspace)
+        (project, workspace, window_handle)
+    }
+
+    /// Creates a file in the given worktree and returns its entry.
+    async fn create_file_in_worktree(
+        worktree: Entity<Worktree>,
+        relative_path: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) -> Entry {
+        cx.update(|cx| {
+            worktree.update(cx, |worktree, cx| {
+                worktree.create_entry(
+                    RelPath::new(relative_path.as_ref(), PathStyle::local())
+                        .unwrap()
+                        .as_ref()
+                        .into(),
+                    false,
+                    None,
+                    cx,
+                )
+            })
+        })
+        .await
+        .unwrap()
+        .into_included()
+        .unwrap()
     }
 
     /// Creates a worktree with 1 folder: /root{suffix}/
@@ -2089,6 +2306,183 @@ mod tests {
         });
     }
 
+    // Terminal drag/drop test
+
+    #[gpui::test]
+    async fn test_handle_drop_writes_paths_for_all_drop_types(cx: &mut TestAppContext) {
+        let (project, _workspace, window_handle) = init_test_with_window(cx).await;
+
+        let (worktree, _) = create_folder_wt(project.clone(), "/root/", cx).await;
+        let first_entry = create_file_in_worktree(worktree.clone(), "first.txt", cx).await;
+        let second_entry = create_file_in_worktree(worktree.clone(), "second.txt", cx).await;
+
+        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
+        let first_path = project
+            .read_with(cx, |project, cx| {
+                project.absolute_path(
+                    &ProjectPath {
+                        worktree_id,
+                        path: first_entry.path.clone(),
+                    },
+                    cx,
+                )
+            })
+            .unwrap();
+        let second_path = project
+            .read_with(cx, |project, cx| {
+                project.absolute_path(
+                    &ProjectPath {
+                        worktree_id,
+                        path: second_entry.path.clone(),
+                    },
+                    cx,
+                )
+            })
+            .unwrap();
+
+        let (active_pane, terminal, terminal_view, tab_item) = window_handle
+            .update(cx, |multi_workspace, window, cx| {
+                let workspace = multi_workspace.workspace().clone();
+                let active_pane = workspace.read(cx).active_pane().clone();
+
+                let terminal = cx.new(|cx| {
+                    terminal::TerminalBuilder::new_display_only(
+                        CursorShape::default(),
+                        terminal::terminal_settings::AlternateScroll::On,
+                        None,
+                        0,
+                        cx.background_executor(),
+                        PathStyle::local(),
+                    )
+                    .unwrap()
+                    .subscribe(cx)
+                });
+                let terminal_view = cx.new(|cx| {
+                    TerminalView::new(
+                        terminal.clone(),
+                        workspace.downgrade(),
+                        None,
+                        project.downgrade(),
+                        window,
+                        cx,
+                    )
+                });
+
+                active_pane.update(cx, |pane, cx| {
+                    pane.add_item(
+                        Box::new(terminal_view.clone()),
+                        true,
+                        false,
+                        None,
+                        window,
+                        cx,
+                    );
+                });
+
+                let tab_project_item = cx.new(|_| TestProjectItem {
+                    entry_id: Some(second_entry.id),
+                    project_path: Some(ProjectPath {
+                        worktree_id,
+                        path: second_entry.path.clone(),
+                    }),
+                    is_dirty: false,
+                });
+                let tab_item =
+                    cx.new(|cx| TestItem::new(cx).with_project_items(&[tab_project_item]));
+                active_pane.update(cx, |pane, cx| {
+                    pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx);
+                });
+
+                (active_pane, terminal, terminal_view, tab_item)
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        window_handle
+            .update(cx, |multi_workspace, window, cx| {
+                let workspace = multi_workspace.workspace().clone();
+                let terminal_view_index =
+                    active_pane.read(cx).index_for_item(&terminal_view).unwrap();
+                let dragged_tab_index = active_pane.read(cx).index_for_item(&tab_item).unwrap();
+
+                assert!(
+                    workspace.read(cx).pane_for(&terminal_view).is_some(),
+                    "terminal view not registered with workspace after run_until_parked"
+                );
+
+                // Dragging an external file should write its path to the terminal
+                let external_paths = ExternalPaths(vec![first_path.clone()].into());
+                assert_drop_writes_to_terminal(
+                    &active_pane,
+                    terminal_view_index,
+                    &terminal,
+                    &external_paths,
+                    &expected_drop_text(std::slice::from_ref(&first_path)),
+                    window,
+                    cx,
+                );
+
+                // Dragging a tab should write the path of the tab's item to the terminal
+                let dragged_tab = DraggedTab {
+                    pane: active_pane.clone(),
+                    item: Box::new(tab_item.clone()),
+                    ix: dragged_tab_index,
+                    detail: 0,
+                    is_active: false,
+                };
+                assert_drop_writes_to_terminal(
+                    &active_pane,
+                    terminal_view_index,
+                    &terminal,
+                    &dragged_tab,
+                    &expected_drop_text(std::slice::from_ref(&second_path)),
+                    window,
+                    cx,
+                );
+
+                // Dragging multiple selections should write both paths to the terminal
+                let dragged_selection = DraggedSelection {
+                    active_selection: SelectedEntry {
+                        worktree_id,
+                        entry_id: first_entry.id,
+                    },
+                    marked_selections: Arc::from([
+                        SelectedEntry {
+                            worktree_id,
+                            entry_id: first_entry.id,
+                        },
+                        SelectedEntry {
+                            worktree_id,
+                            entry_id: second_entry.id,
+                        },
+                    ]),
+                };
+                assert_drop_writes_to_terminal(
+                    &active_pane,
+                    terminal_view_index,
+                    &terminal,
+                    &dragged_selection,
+                    &expected_drop_text(&[first_path.clone(), second_path.clone()]),
+                    window,
+                    cx,
+                );
+
+                // Dropping a project entry should write the entry's path to the terminal
+                let dropped_entry_id = first_entry.id;
+                assert_drop_writes_to_terminal(
+                    &active_pane,
+                    terminal_view_index,
+                    &terminal,
+                    &dropped_entry_id,
+                    &expected_drop_text(&[first_path]),
+                    window,
+                    cx,
+                );
+            })
+            .unwrap();
+    }
+
     // Terminal rename tests
 
     #[gpui::test]

crates/text/Cargo.toml 🔗

@@ -35,5 +35,4 @@ ctor.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 rand.workspace = true
 util = { workspace = true, features = ["test-support"] }
-http_client = { workspace = true, features = ["test-support"] }
 zlog.workspace = true

crates/text/src/anchor.rs 🔗

@@ -15,8 +15,8 @@ pub struct Anchor {
     // we store the replica id and sequence number of the timestamp inline
     // to avoid the alignment of our fields from increasing the size of this struct
     // This saves 8 bytes, by allowing replica id, value and bias to occupy the padding
-    timestamp_replica_id: clock::ReplicaId,
-    timestamp_value: clock::Seq,
+    pub(crate) timestamp_replica_id: clock::ReplicaId,
+    pub(crate) timestamp_value: clock::Seq,
 
     /// The byte offset into the text inserted in the operation
     /// at `timestamp`.

crates/text/src/text.rs 🔗

@@ -2379,13 +2379,22 @@ impl BufferSnapshot {
                     anchor
                 );
             };
+            // TODO verbose debug because we are seeing is_max return false unexpectedly,
+            // remove this once that is understood and fixed
             assert_eq!(
                 insertion.timestamp,
                 anchor.timestamp(),
-                "invalid insertion for buffer {}@{:?} and anchor {:?}",
+                "invalid insertion for buffer {}@{:?}. anchor: {:?}, {:?}, {:?}, {:?}, {:?}. timestamp: {:?}, offset: {:?}, bias: {:?}",
                 self.remote_id(),
                 self.version,
-                anchor
+                anchor.timestamp_replica_id,
+                anchor.timestamp_value,
+                anchor.offset,
+                anchor.bias,
+                anchor.buffer_id,
+                anchor.timestamp() == clock::Lamport::MAX,
+                anchor.offset == u32::MAX,
+                anchor.bias == Bias::Right,
             );
 
             fragment_cursor.seek_forward(&Some(&insertion.fragment_id), Bias::Left);

crates/theme/src/settings.rs 🔗

@@ -378,14 +378,14 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) {
 
     if let Some(selection) = theme.theme.as_mut() {
         match selection {
-            settings::ThemeSelection::Static(theme) => {
+            settings::ThemeSelection::Static(_) => {
                 // If the theme was previously set to a single static theme,
-                // we don't know whether it was a light or dark theme, so we
-                // just use it for both.
+                // reset to the default dynamic light/dark pair and let users
+                // customize light/dark themes explicitly afterward.
                 *selection = settings::ThemeSelection::Dynamic {
-                    mode,
-                    light: theme.clone(),
-                    dark: theme.clone(),
+                    mode: ThemeAppearanceMode::System,
+                    light: ThemeName(settings::DEFAULT_LIGHT_THEME.into()),
+                    dark: ThemeName(settings::DEFAULT_DARK_THEME.into()),
                 };
             }
             settings::ThemeSelection::Dynamic {

crates/theme_selector/src/icon_theme_selector.rs 🔗

@@ -311,10 +311,11 @@ impl PickerDelegate for IconThemeSelectorDelegate {
                 .border_color(cx.theme().colors().border_variant)
                 .child(
                     Button::new("docs", "View Icon Theme Docs")
-                        .icon(IconName::ArrowUpRight)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
+                        .end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .on_click(|_event, _window, cx| {
                             cx.open_url("https://zed.dev/docs/icon-themes");
                         }),

crates/theme_selector/src/theme_selector.rs 🔗

@@ -497,10 +497,11 @@ impl PickerDelegate for ThemeSelectorDelegate {
                 .border_color(cx.theme().colors().border_variant)
                 .child(
                     Button::new("docs", "View Theme Docs")
-                        .icon(IconName::ArrowUpRight)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
+                        .end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .on_click(cx.listener(|_, _, _, cx| {
                             cx.open_url("https://zed.dev/docs/themes");
                         })),

crates/title_bar/Cargo.toml 🔗

@@ -18,9 +18,9 @@ stories = ["dep:story"]
 test-support = [
     "call/test-support",
     "client/test-support",
-    "collections/test-support",
+
     "gpui/test-support",
-    "http_client/test-support",
+
     "project/test-support",
     "remote/test-support",
     "util/test-support",
@@ -38,7 +38,6 @@ chrono.workspace = true
 client.workspace = true
 cloud_api_types.workspace = true
 db.workspace = true
-feature_flags.workspace = true
 git_ui.workspace = true
 gpui = { workspace = true, features = ["screen-capture"] }
 notifications.workspace = true
@@ -65,17 +64,13 @@ windows.workspace = true
 [dev-dependencies]
 call = { workspace = true, features = ["test-support"] }
 client = { workspace = true, features = ["test-support"] }
-collections = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
-http_client = { workspace = true, features = ["test-support"] }
 notifications = { workspace = true, features = ["test-support"] }
-pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 release_channel.workspace = true
 remote = { workspace = true, features = ["test-support"] }
 rpc = { workspace = true, features = ["test-support"] }
 semver.workspace = true
 settings = { workspace = true, features = ["test-support"] }
-tree-sitter-md.workspace = true
 util = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }

crates/title_bar/src/plan_chip.rs 🔗

@@ -33,6 +33,7 @@ impl RenderOnce for PlanChip {
             Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
             Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
             Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
+            Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg),
             Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg),
         };
 

crates/title_bar/src/title_bar.rs 🔗

@@ -24,16 +24,13 @@ use auto_update::AutoUpdateStatus;
 use call::ActiveCall;
 use client::{Client, UserStore, zed_urls};
 use cloud_api_types::Plan;
-use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
     Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
     StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div,
 };
 use onboarding_banner::OnboardingBanner;
-use project::{
-    DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
-};
+use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
 use remote::RemoteConnectionOptions;
 use settings::Settings;
 use settings::WorktreeId;
@@ -47,8 +44,7 @@ use ui::{
 use update_version::UpdateVersion;
 use util::ResultExt;
 use workspace::{
-    MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
-    notifications::NotifyResultExt,
+    MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
 };
 use zed_actions::OpenRemote;
 
@@ -151,6 +147,7 @@ pub struct TitleBar {
     user_store: Entity<UserStore>,
     client: Arc<Client>,
     workspace: WeakEntity<Workspace>,
+    multi_workspace: Option<WeakEntity<MultiWorkspace>>,
     application_menu: Option<Entity<ApplicationMenu>>,
     _subscriptions: Vec<Subscription>,
     banner: Entity<OnboardingBanner>,
@@ -173,7 +170,6 @@ impl Render for TitleBar {
                     let mut render_project_items = title_bar_settings.show_branch_name
                         || title_bar_settings.show_project_items;
                     title_bar
-                        .children(self.render_workspace_sidebar_toggle(window, cx))
                         .when_some(
                             self.application_menu.clone().filter(|_| !show_menus),
                             |title_bar, menu| {
@@ -188,7 +184,7 @@ impl Render for TitleBar {
                                 .when(title_bar_settings.show_project_items, |title_bar| {
                                     title_bar
                                         .children(self.render_project_host(cx))
-                                        .child(self.render_project_name(cx))
+                                        .child(self.render_project_name(window, cx))
                                 })
                                 .when(title_bar_settings.show_branch_name, |title_bar| {
                                     title_bar.children(self.render_project_branch(cx))
@@ -356,7 +352,6 @@ impl TitleBar {
 
         // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar.
         {
-            let platform_titlebar = platform_titlebar.clone();
             let window_handle = window.window_handle();
             cx.spawn(async move |this: WeakEntity<TitleBar>, cx| {
                 let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
@@ -369,26 +364,9 @@ impl TitleBar {
                         return;
                     };
 
-                    let is_open = multi_workspace.read(cx).is_sidebar_open();
-                    let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx);
-                    platform_titlebar.update(cx, |titlebar, cx| {
-                        titlebar.set_workspace_sidebar_open(is_open, cx);
-                        titlebar.set_sidebar_has_notifications(has_notifications, cx);
-                    });
-
-                    let platform_titlebar = platform_titlebar.clone();
-                    let subscription = cx.observe(&multi_workspace, move |mw, cx| {
-                        let is_open = mw.read(cx).is_sidebar_open();
-                        let has_notifications = mw.read(cx).sidebar_has_notifications(cx);
-                        platform_titlebar.update(cx, |titlebar, cx| {
-                            titlebar.set_workspace_sidebar_open(is_open, cx);
-                            titlebar.set_sidebar_has_notifications(has_notifications, cx);
-                        });
-                    });
-
                     if let Some(this) = this.upgrade() {
                         this.update(cx, |this, _| {
-                            this._subscriptions.push(subscription);
+                            this.multi_workspace = Some(multi_workspace.downgrade());
                         });
                     }
                 });
@@ -400,6 +378,7 @@ impl TitleBar {
             platform_titlebar,
             application_menu,
             workspace: workspace.weak_handle(),
+            multi_workspace: None,
             project,
             user_store,
             client,
@@ -604,10 +583,11 @@ impl TitleBar {
             .style(ButtonStyle::Tinted(TintColor::Warning))
             .label_size(LabelSize::Small)
             .color(Color::Warning)
-            .icon(IconName::Warning)
-            .icon_color(Color::Warning)
-            .icon_size(IconSize::Small)
-            .icon_position(IconPosition::Start)
+            .start_icon(
+                Icon::new(IconName::Warning)
+                    .size(IconSize::Small)
+                    .color(Color::Warning),
+            )
             .tooltip(|_, cx| {
                 Tooltip::with_meta(
                     "You're in Restricted Mode",
@@ -683,42 +663,7 @@ impl TitleBar {
         )
     }
 
-    fn render_workspace_sidebar_toggle(
-        &self,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Option<AnyElement> {
-        if !cx.has_flag::<AgentV2FeatureFlag>() || DisableAiSettings::get_global(cx).disable_ai {
-            return None;
-        }
-
-        let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
-
-        if is_sidebar_open {
-            return None;
-        }
-
-        let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
-
-        Some(
-            IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
-                .icon_size(IconSize::Small)
-                .when(has_notifications, |button| {
-                    button
-                        .indicator(Indicator::dot().color(Color::Accent))
-                        .indicator_border_color(Some(cx.theme().colors().title_bar_background))
-                })
-                .tooltip(move |_, cx| {
-                    Tooltip::for_action("Open Workspace Sidebar", &ToggleWorkspaceSidebar, cx)
-                })
-                .on_click(|_, window, cx| {
-                    window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
-                })
-                .into_any_element(),
-        )
-    }
-
-    pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
+    pub fn render_project_name(&self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let workspace = self.workspace.clone();
 
         let name = self.effective_active_worktree(cx).map(|worktree| {
@@ -753,9 +698,11 @@ impl TitleBar {
                 Button::new("project_name_trigger", display_name)
                     .label_size(LabelSize::Small)
                     .when(self.worktree_count(cx) > 1, |this| {
-                        this.icon(IconName::ChevronDown)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::XSmall)
+                        this.end_icon(
+                            Icon::new(IconName::ChevronDown)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                     })
                     .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                     .when(!is_project_selected, |s| s.color(Color::Muted)),
@@ -835,11 +782,9 @@ impl TitleBar {
                         .color(Color::Muted)
                         .when(settings.show_branch_icon, |branch_button| {
                             let (icon, icon_color) = icon_info;
-                            branch_button
-                                .icon(icon)
-                                .icon_position(IconPosition::Start)
-                                .icon_color(icon_color)
-                                .icon_size(IconSize::Indicator)
+                            branch_button.start_icon(
+                                Icon::new(icon).size(IconSize::Indicator).color(icon_color),
+                            )
                         }),
                     move |_window, cx| {
                         Tooltip::with_meta(
@@ -1014,9 +959,9 @@ impl TitleBar {
                                     let user_store = user_store.clone();
                                     let organization = organization.clone();
                                     move |_window, cx| {
-                                        user_store.update(cx, |user_store, _cx| {
+                                        user_store.update(cx, |user_store, cx| {
                                             user_store
-                                                .set_current_organization(organization.clone());
+                                                .set_current_organization(organization.clone(), cx);
                                         });
                                     }
                                 },

crates/ui/src/components.rs 🔗

@@ -12,6 +12,7 @@ mod disclosure;
 mod divider;
 mod dropdown_menu;
 mod facepile;
+mod gradient_fade;
 mod group;
 mod icon;
 mod image;
@@ -54,6 +55,7 @@ pub use disclosure::*;
 pub use divider::*;
 pub use dropdown_menu::*;
 pub use facepile::*;
+pub use gradient_fade::*;
 pub use group::*;
 pub use icon::*;
 pub use image::*;

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

@@ -1,5 +1,7 @@
 mod configured_api_card;
 mod thread_item;
+mod thread_sidebar_toggle;
 
 pub use configured_api_card::*;
 pub use thread_item::*;
+pub use thread_sidebar_toggle::*;

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

@@ -1,7 +1,7 @@
 use crate::{Tooltip, prelude::*};
 use gpui::{ClickEvent, IntoElement, ParentElement, SharedString};
 
-#[derive(IntoElement)]
+#[derive(IntoElement, RegisterComponent)]
 pub struct ConfiguredApiCard {
     label: SharedString,
     button_label: Option<SharedString>,
@@ -52,6 +52,59 @@ impl ConfiguredApiCard {
     }
 }
 
+impl Component for ConfiguredApiCard {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || {
+            v_flex()
+                .w_72()
+                .p_2()
+                .gap_2()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().panel_background)
+        };
+
+        let examples = vec![
+            single_example(
+                "Default",
+                container()
+                    .child(ConfiguredApiCard::new("API key is configured"))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Custom Button Label",
+                container()
+                    .child(
+                        ConfiguredApiCard::new("OpenAI API key configured")
+                            .button_label("Remove Key"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "With Tooltip",
+                container()
+                    .child(
+                        ConfiguredApiCard::new("Anthropic API key configured")
+                            .tooltip_label("Click to reset your API key"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Disabled",
+                container()
+                    .child(ConfiguredApiCard::new("API key is configured").disabled(true))
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).into_any_element())
+    }
+}
+
 impl RenderOnce for ConfiguredApiCard {
     fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
         let button_label = self.button_label.unwrap_or("Reset Key".into());
@@ -80,10 +133,11 @@ impl RenderOnce for ConfiguredApiCard {
                         elem.tab_index(tab_index)
                     })
                     .label_size(LabelSize::Small)
-                    .icon(IconName::Undo)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
-                    .icon_position(IconPosition::Start)
+                    .start_icon(
+                        Icon::new(IconName::Undo)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .disabled(self.disabled)
                     .when_some(self.tooltip_label, |this, label| {
                         this.tooltip(Tooltip::text(label))

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

@@ -1,6 +1,6 @@
 use crate::{
-    DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
-    prelude::*,
+    CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration,
+    IconDecorationKind, prelude::*,
 };
 
 use gpui::{AnyView, ClickEvent, Hsla, SharedString};
@@ -24,7 +24,9 @@ pub struct ThreadItem {
     notified: bool,
     status: AgentThreadStatus,
     selected: bool,
+    focused: bool,
     hovered: bool,
+    docked_right: bool,
     added: Option<usize>,
     removed: Option<usize>,
     worktree: Option<SharedString>,
@@ -47,7 +49,9 @@ impl ThreadItem {
             notified: false,
             status: AgentThreadStatus::default(),
             selected: false,
+            focused: false,
             hovered: false,
+            docked_right: false,
             added: None,
             removed: None,
             worktree: None,
@@ -90,6 +94,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn focused(mut self, focused: bool) -> Self {
+        self.focused = focused;
+        self
+    }
+
     pub fn added(mut self, added: usize) -> Self {
         self.added = Some(added);
         self
@@ -100,6 +109,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn docked_right(mut self, docked_right: bool) -> Self {
+        self.docked_right = docked_right;
+        self
+    }
+
     pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
         self.worktree = Some(worktree.into());
         self
@@ -146,15 +160,15 @@ impl ThreadItem {
 
 impl RenderOnce for ThreadItem {
     fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
-        let clr = cx.theme().colors();
-        // let dot_separator = || {
-        //     Label::new("•")
-        //         .size(LabelSize::Small)
-        //         .color(Color::Muted)
-        //         .alpha(0.5)
-        // };
-
-        let icon_container = || h_flex().size_4().justify_center();
+        let color = cx.theme().colors();
+        let dot_separator = || {
+            Label::new("•")
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+                .alpha(0.5)
+        };
+
+        let icon_container = || h_flex().size_4().flex_none().justify_center();
         let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
             Icon::from_external_svg(custom_svg)
                 .color(Color::Muted)
@@ -182,46 +196,75 @@ impl RenderOnce for ThreadItem {
         } else if self.status == AgentThreadStatus::Error {
             Some(decoration(IconDecorationKind::X, cx.theme().status().error))
         } else if self.notified {
-            Some(decoration(IconDecorationKind::Dot, clr.text_accent))
+            Some(decoration(IconDecorationKind::Dot, color.text_accent))
         } else {
             None
         };
 
-        let icon = if let Some(decoration) = decoration {
-            icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
-        } else {
-            icon_container().child(agent_icon)
-        };
-
         let is_running = matches!(
             self.status,
             AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
         );
-        let running_or_action = is_running || (self.hovered && self.action_slot.is_some());
+
+        let icon = if is_running {
+            icon_container().child(
+                Icon::new(IconName::LoadCircle)
+                    .size(IconSize::Small)
+                    .color(Color::Muted)
+                    .with_rotate_animation(2),
+            )
+        } else if let Some(decoration) = decoration {
+            icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
+        } else {
+            icon_container().child(agent_icon)
+        };
 
         let title = self.title;
         let highlight_positions = self.highlight_positions;
         let title_label = if highlight_positions.is_empty() {
-            Label::new(title).truncate().into_any_element()
+            Label::new(title).into_any_element()
+        } else {
+            HighlightedLabel::new(title, highlight_positions).into_any_element()
+        };
+
+        let base_bg = if self.selected {
+            color.element_active
         } else {
-            HighlightedLabel::new(title, highlight_positions)
-                .truncate()
-                .into_any_element()
+            color.panel_background
         };
 
+        let gradient_overlay =
+            GradientFade::new(base_bg, color.element_hover, color.element_active)
+                .width(px(64.0))
+                .right(px(-10.0))
+                .gradient_stop(0.75)
+                .group_name("thread-item");
+
+        let has_diff_stats = self.added.is_some() || self.removed.is_some();
+        let added_count = self.added.unwrap_or(0);
+        let removed_count = self.removed.unwrap_or(0);
+        let diff_stat_id = self.id.clone();
+        let has_worktree = self.worktree.is_some();
+        let has_timestamp = !self.timestamp.is_empty();
+        let timestamp = self.timestamp;
+
         v_flex()
             .id(self.id.clone())
+            .group("thread-item")
+            .relative()
+            .overflow_hidden()
             .cursor_pointer()
             .w_full()
-            .map(|this| {
-                if self.worktree.is_some() {
-                    this.p_2()
-                } else {
-                    this.px_2().py_1()
-                }
+            .p_1()
+            .when(self.selected, |s| s.bg(color.element_active))
+            .border_1()
+            .border_color(gpui::transparent_black())
+            .when(self.focused, |s| {
+                s.when(self.docked_right, |s| s.border_r_2())
+                    .border_color(color.border_focused)
             })
-            .when(self.selected, |s| s.bg(clr.element_active))
-            .hover(|s| s.bg(clr.element_hover))
+            .hover(|s| s.bg(color.element_hover))
+            .active(|s| s.bg(color.element_active))
             .on_hover(self.on_hover)
             .child(
                 h_flex()
@@ -239,20 +282,9 @@ impl RenderOnce for ThreadItem {
                             .child(title_label)
                             .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
                     )
-                    .when(running_or_action, |this| {
-                        this.child(
-                            h_flex()
-                                .gap_1()
-                                .when(is_running, |this| {
-                                    this.child(
-                                        icon_container()
-                                            .child(SpinnerLabel::new().color(Color::Accent)),
-                                    )
-                                })
-                                .when(self.hovered, |this| {
-                                    this.when_some(self.action_slot, |this, slot| this.child(slot))
-                                }),
-                        )
+                    .child(gradient_overlay)
+                    .when(self.hovered, |this| {
+                        this.when_some(self.action_slot, |this, slot| this.child(slot))
                     }),
             )
             .when_some(self.worktree, |this, worktree| {
@@ -261,7 +293,6 @@ impl RenderOnce for ThreadItem {
                     Label::new(worktree)
                         .size(LabelSize::Small)
                         .color(Color::Muted)
-                        .truncate_start()
                         .into_any_element()
                 } else {
                     HighlightedLabel::new(worktree, worktree_highlight_positions)
@@ -276,32 +307,48 @@ impl RenderOnce for ThreadItem {
                         .gap_1p5()
                         .child(icon_container()) // Icon Spacing
                         .child(worktree_label)
-                        // TODO: Uncomment the elements below when we're ready to expose this data
-                        // .child(dot_separator())
-                        // .child(
-                        //     Label::new(self.timestamp)
-                        //         .size(LabelSize::Small)
-                        //         .color(Color::Muted),
-                        // )
-                        // .child(
-                        //     Label::new("•")
-                        //         .size(LabelSize::Small)
-                        //         .color(Color::Muted)
-                        //         .alpha(0.5),
-                        // )
-                        // .when(has_no_changes, |this| {
-                        //     this.child(
-                        //         Label::new("No Changes")
-                        //             .size(LabelSize::Small)
-                        //             .color(Color::Muted),
-                        //     )
-                        // })
-                        .when(self.added.is_some() || self.removed.is_some(), |this| {
-                            this.child(DiffStat::new(
-                                self.id,
-                                self.added.unwrap_or(0),
-                                self.removed.unwrap_or(0),
-                            ))
+                        .when(has_diff_stats || has_timestamp, |this| {
+                            this.child(dot_separator())
+                        })
+                        .when(has_diff_stats, |this| {
+                            this.child(
+                                DiffStat::new(diff_stat_id.clone(), added_count, removed_count)
+                                    .tooltip("Unreviewed changes"),
+                            )
+                        })
+                        .when(has_diff_stats && has_timestamp, |this| {
+                            this.child(dot_separator())
+                        })
+                        .when(has_timestamp, |this| {
+                            this.child(
+                                Label::new(timestamp.clone())
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                        }),
+                )
+            })
+            .when(!has_worktree && (has_diff_stats || has_timestamp), |this| {
+                this.child(
+                    h_flex()
+                        .min_w_0()
+                        .gap_1p5()
+                        .child(icon_container()) // Icon Spacing
+                        .when(has_diff_stats, |this| {
+                            this.child(
+                                DiffStat::new(diff_stat_id, added_count, removed_count)
+                                    .tooltip("Unreviewed changes"),
+                            )
+                        })
+                        .when(has_diff_stats && has_timestamp, |this| {
+                            this.child(dot_separator())
+                        })
+                        .when(has_timestamp, |this| {
+                            this.child(
+                                Label::new(timestamp.clone())
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
                         }),
                 )
             })
@@ -325,21 +372,31 @@ impl Component for ThreadItem {
 
         let thread_item_examples = vec![
             single_example(
-                "Default",
+                "Default (minutes)",
                 container()
                     .child(
                         ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
                             .icon(IconName::AiOpenAi)
-                            .timestamp("1:33 AM"),
+                            .timestamp("15m"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Timestamp Only (hours)",
+                container()
+                    .child(
+                        ThreadItem::new("ti-1b", "Thread with just a timestamp")
+                            .icon(IconName::AiClaude)
+                            .timestamp("3h"),
                     )
                     .into_any_element(),
             ),
             single_example(
-                "Notified",
+                "Notified (weeks)",
                 container()
                     .child(
                         ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
-                            .timestamp("12:12 AM")
+                            .timestamp("1w")
                             .notified(true),
                     )
                     .into_any_element(),
@@ -349,7 +406,7 @@ impl Component for ThreadItem {
                 container()
                     .child(
                         ThreadItem::new("ti-2b", "Execute shell command in terminal")
-                            .timestamp("12:15 AM")
+                            .timestamp("2h")
                             .status(AgentThreadStatus::WaitingForConfirmation),
                     )
                     .into_any_element(),
@@ -359,7 +416,7 @@ impl Component for ThreadItem {
                 container()
                     .child(
                         ThreadItem::new("ti-2c", "Failed to connect to language server")
-                            .timestamp("12:20 AM")
+                            .timestamp("5h")
                             .status(AgentThreadStatus::Error),
                     )
                     .into_any_element(),
@@ -370,7 +427,7 @@ impl Component for ThreadItem {
                     .child(
                         ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
                             .icon(IconName::AiClaude)
-                            .timestamp("7:30 PM")
+                            .timestamp("23h")
                             .status(AgentThreadStatus::Running),
                     )
                     .into_any_element(),
@@ -381,34 +438,121 @@ impl Component for ThreadItem {
                     .child(
                         ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
                             .icon(IconName::AiClaude)
-                            .timestamp("7:37 PM")
+                            .timestamp("2w")
                             .worktree("link-agent-panel"),
                     )
                     .into_any_element(),
             ),
             single_example(
-                "With Changes",
+                "With Changes (months)",
                 container()
                     .child(
                         ThreadItem::new("ti-5", "Managing user and project settings interactions")
                             .icon(IconName::AiClaude)
-                            .timestamp("7:37 PM")
+                            .timestamp("1mo")
                             .added(10)
                             .removed(3),
                     )
                     .into_any_element(),
             ),
+            single_example(
+                "Worktree + Changes + Timestamp",
+                container()
+                    .child(
+                        ThreadItem::new("ti-5b", "Full metadata example")
+                            .icon(IconName::AiClaude)
+                            .worktree("my-project")
+                            .added(42)
+                            .removed(17)
+                            .timestamp("3w"),
+                    )
+                    .into_any_element(),
+            ),
             single_example(
                 "Selected Item",
                 container()
                     .child(
                         ThreadItem::new("ti-6", "Refine textarea interaction behavior")
                             .icon(IconName::AiGemini)
-                            .timestamp("3:00 PM")
+                            .timestamp("45m")
                             .selected(true),
                     )
                     .into_any_element(),
             ),
+            single_example(
+                "Focused Item (Keyboard Selection)",
+                container()
+                    .child(
+                        ThreadItem::new("ti-7", "Implement keyboard navigation")
+                            .icon(IconName::AiClaude)
+                            .timestamp("12h")
+                            .focused(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Focused + Docked Right",
+                container()
+                    .child(
+                        ThreadItem::new("ti-7b", "Focused with right dock border")
+                            .icon(IconName::AiClaude)
+                            .timestamp("1w")
+                            .focused(true)
+                            .docked_right(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Selected + Focused",
+                container()
+                    .child(
+                        ThreadItem::new("ti-8", "Active and keyboard-focused thread")
+                            .icon(IconName::AiGemini)
+                            .timestamp("2mo")
+                            .selected(true)
+                            .focused(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Hovered with Action Slot",
+                container()
+                    .child(
+                        ThreadItem::new("ti-9", "Hover to see action button")
+                            .icon(IconName::AiClaude)
+                            .timestamp("6h")
+                            .hovered(true)
+                            .action_slot(
+                                IconButton::new("delete", IconName::Trash)
+                                    .icon_size(IconSize::Small)
+                                    .icon_color(Color::Muted),
+                            ),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Search Highlight",
+                container()
+                    .child(
+                        ThreadItem::new("ti-10", "Implement keyboard navigation")
+                            .icon(IconName::AiClaude)
+                            .timestamp("4w")
+                            .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Worktree Search Highlight",
+                container()
+                    .child(
+                        ThreadItem::new("ti-11", "Search in worktree name")
+                            .icon(IconName::AiClaude)
+                            .timestamp("3mo")
+                            .worktree("my-project-name")
+                            .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]),
+                    )
+                    .into_any_element(),
+            ),
         ];
 
         Some(

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

@@ -0,0 +1,177 @@
+use gpui::{AnyView, ClickEvent};
+use ui_macros::RegisterComponent;
+
+use crate::prelude::*;
+use crate::{IconButton, IconName, Tooltip};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct ThreadSidebarToggle {
+    sidebar_selected: bool,
+    thread_selected: bool,
+    flipped: bool,
+    sidebar_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
+    thread_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
+    on_sidebar_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+    on_thread_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+}
+
+impl ThreadSidebarToggle {
+    pub fn new() -> Self {
+        Self {
+            sidebar_selected: false,
+            thread_selected: false,
+            flipped: false,
+            sidebar_tooltip: None,
+            thread_tooltip: None,
+            on_sidebar_click: None,
+            on_thread_click: None,
+        }
+    }
+
+    pub fn sidebar_selected(mut self, selected: bool) -> Self {
+        self.sidebar_selected = selected;
+        self
+    }
+
+    pub fn thread_selected(mut self, selected: bool) -> Self {
+        self.thread_selected = selected;
+        self
+    }
+
+    pub fn flipped(mut self, flipped: bool) -> Self {
+        self.flipped = flipped;
+        self
+    }
+
+    pub fn sidebar_tooltip(
+        mut self,
+        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
+    ) -> Self {
+        self.sidebar_tooltip = Some(Box::new(tooltip));
+        self
+    }
+
+    pub fn thread_tooltip(
+        mut self,
+        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
+    ) -> Self {
+        self.thread_tooltip = Some(Box::new(tooltip));
+        self
+    }
+
+    pub fn on_sidebar_click(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_sidebar_click = Some(Box::new(handler));
+        self
+    }
+
+    pub fn on_thread_click(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_thread_click = Some(Box::new(handler));
+        self
+    }
+}
+
+impl RenderOnce for ThreadSidebarToggle {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let sidebar_icon = match (self.sidebar_selected, self.flipped) {
+            (true, false) => IconName::ThreadsSidebarLeftOpen,
+            (false, false) => IconName::ThreadsSidebarLeftClosed,
+            (true, true) => IconName::ThreadsSidebarRightOpen,
+            (false, true) => IconName::ThreadsSidebarRightClosed,
+        };
+
+        h_flex()
+            .min_w_0()
+            .rounded_sm()
+            .gap_px()
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .when(self.flipped, |this| this.flex_row_reverse())
+            .child(
+                IconButton::new("sidebar-toggle", sidebar_icon)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(self.sidebar_selected)
+                    .when_some(self.sidebar_tooltip, |this, tooltip| this.tooltip(tooltip))
+                    .when_some(self.on_sidebar_click, |this, handler| {
+                        this.on_click(handler)
+                    }),
+            )
+            .child(div().h_4().w_px().bg(cx.theme().colors().border))
+            .child(
+                IconButton::new("thread-toggle", IconName::Thread)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(self.thread_selected)
+                    .when_some(self.thread_tooltip, |this, tooltip| this.tooltip(tooltip))
+                    .when_some(self.on_thread_click, |this, handler| this.on_click(handler)),
+            )
+    }
+}
+
+impl Component for ThreadSidebarToggle {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || div().p_2().bg(cx.theme().colors().status_bar_background);
+
+        let examples = vec![
+            single_example(
+                "Both Unselected",
+                container()
+                    .child(ThreadSidebarToggle::new())
+                    .into_any_element(),
+            ),
+            single_example(
+                "Sidebar Selected",
+                container()
+                    .child(ThreadSidebarToggle::new().sidebar_selected(true))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Thread Selected",
+                container()
+                    .child(ThreadSidebarToggle::new().thread_selected(true))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Both Selected",
+                container()
+                    .child(
+                        ThreadSidebarToggle::new()
+                            .sidebar_selected(true)
+                            .thread_selected(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Flipped",
+                container()
+                    .child(
+                        ThreadSidebarToggle::new()
+                            .sidebar_selected(true)
+                            .thread_selected(true)
+                            .flipped(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "With Tooltips",
+                container()
+                    .child(
+                        ThreadSidebarToggle::new()
+                            .sidebar_tooltip(Tooltip::text("Toggle Sidebar"))
+                            .thread_tooltip(Tooltip::text("Toggle Thread")),
+                    )
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).into_any_element())
+    }
+}

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

@@ -8,16 +8,14 @@ use gpui::{AnyElement, IntoElement, ParentElement, Styled};
 ///
 /// ```
 /// use ui::prelude::*;
-/// use ui::{Banner, Button, IconName, IconPosition, IconSize, Label, Severity};
+/// use ui::{Banner, Button, Icon, IconName, IconSize, Label, Severity};
 ///
 /// Banner::new()
 ///     .severity(Severity::Success)
 ///     .children([Label::new("This is a success message")])
 ///     .action_slot(
 ///         Button::new("learn-more", "Learn More")
-///             .icon(IconName::ArrowUpRight)
-///             .icon_size(IconSize::Small)
-///             .icon_position(IconPosition::End)
+///             .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)),
 ///     );
 /// ```
 #[derive(IntoElement, RegisterComponent)]
@@ -151,9 +149,7 @@ impl Component for Banner {
                     .child(Label::new("This is an informational message"))
                     .action_slot(
                         Button::new("learn-more", "Learn More")
-                            .icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::Small)
-                            .icon_position(IconPosition::End),
+                            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)),
                     )
                     .into_any_element(),
             ),

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

@@ -2,15 +2,12 @@ use crate::component_prelude::*;
 use gpui::{AnyElement, AnyView, DefiniteLength};
 use ui_macros::RegisterComponent;
 
-use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label};
+use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, Label};
 use crate::{
-    Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding, KeybindingPosition, TintColor,
-    prelude::*,
+    Color, DynamicSpacing, ElevationIndex, KeyBinding, KeybindingPosition, TintColor, prelude::*,
 };
 
-use super::button_icon::ButtonIcon;
-
-/// An element that creates a button with a label and an optional icon.
+/// An element that creates a button with a label and optional icons.
 ///
 /// Common buttons:
 /// - Label, Icon + Label: [`Button`] (this component)
@@ -42,7 +39,7 @@ use super::button_icon::ButtonIcon;
 /// use ui::prelude::*;
 ///
 /// Button::new("button_id", "Click me!")
-///     .icon(IconName::Check)
+///     .start_icon(Icon::new(IconName::Check))
 ///     .toggle_state(true)
 ///     .on_click(|event, window, cx| {
 ///         // Handle click event
@@ -85,12 +82,8 @@ pub struct Button {
     label_size: Option<LabelSize>,
     selected_label: Option<SharedString>,
     selected_label_color: Option<Color>,
-    icon: Option<IconName>,
-    icon_position: Option<IconPosition>,
-    icon_size: Option<IconSize>,
-    icon_color: Option<Color>,
-    selected_icon: Option<IconName>,
-    selected_icon_color: Option<Color>,
+    start_icon: Option<Icon>,
+    end_icon: Option<Icon>,
     key_binding: Option<KeyBinding>,
     key_binding_position: KeybindingPosition,
     alpha: Option<f32>,
@@ -112,12 +105,8 @@ impl Button {
             label_size: None,
             selected_label: None,
             selected_label_color: None,
-            icon: None,
-            icon_position: None,
-            icon_size: None,
-            icon_color: None,
-            selected_icon: None,
-            selected_icon_color: None,
+            start_icon: None,
+            end_icon: None,
             key_binding: None,
             key_binding_position: KeybindingPosition::default(),
             alpha: None,
@@ -149,39 +138,19 @@ impl Button {
         self
     }
 
-    /// Assigns an icon to the button.
-    pub fn icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
-        self.icon = icon.into();
-        self
-    }
-
-    /// Sets the position of the icon relative to the label.
-    pub fn icon_position(mut self, icon_position: impl Into<Option<IconPosition>>) -> Self {
-        self.icon_position = icon_position.into();
-        self
-    }
-
-    /// Specifies the size of the button's icon.
-    pub fn icon_size(mut self, icon_size: impl Into<Option<IconSize>>) -> Self {
-        self.icon_size = icon_size.into();
-        self
-    }
-
-    /// Sets the color of the button's icon.
-    pub fn icon_color(mut self, icon_color: impl Into<Option<Color>>) -> Self {
-        self.icon_color = icon_color.into();
-        self
-    }
-
-    /// Chooses an icon to display when the button is in a selected state.
-    pub fn selected_icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
-        self.selected_icon = icon.into();
+    /// Sets an icon to display at the start (left) of the button label.
+    ///
+    /// The icon's color will be overridden to `Color::Disabled` when the button is disabled.
+    pub fn start_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
+        self.start_icon = icon.into();
         self
     }
 
-    /// Sets the icon color used when the button is in a selected state.
-    pub fn selected_icon_color(mut self, color: impl Into<Option<Color>>) -> Self {
-        self.selected_icon_color = color.into();
+    /// Sets an icon to display at the end (right) of the button label.
+    ///
+    /// The icon's color will be overridden to `Color::Disabled` when the button is disabled.
+    pub fn end_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
+        self.end_icon = icon.into();
         self
     }
 
@@ -219,22 +188,24 @@ impl Button {
 impl Toggleable for Button {
     /// Sets the selected state of the button.
     ///
-    /// This method allows the selection state of the button to be specified.
-    /// It modifies the button's appearance to reflect its selected state.
-    ///
     /// # Examples
     ///
+    /// Create a toggleable button that changes appearance when selected:
+    ///
     /// ```
     /// use ui::prelude::*;
+    /// use ui::TintColor;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .toggle_state(true)
+    /// let selected = true;
+    ///
+    /// Button::new("toggle_button", "Toggle Me")
+    ///     .start_icon(Icon::new(IconName::Check))
+    ///     .toggle_state(selected)
+    ///     .selected_style(ButtonStyle::Tinted(TintColor::Accent))
     ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
+    ///         // Toggle the selected state
     ///     });
     /// ```
-    ///
-    /// Use [`selected_style`](Button::selected_style) to change the style of the button when it is selected.
     fn toggle_state(mut self, selected: bool) -> Self {
         self.base = self.base.toggle_state(selected);
         self
@@ -242,22 +213,20 @@ impl Toggleable for Button {
 }
 
 impl SelectableButton for Button {
-    /// Sets the style for the button when selected.
+    /// Sets the style for the button in a selected state.
     ///
     /// # Examples
     ///
+    /// Customize the selected appearance of a button:
+    ///
     /// ```
     /// use ui::prelude::*;
     /// use ui::TintColor;
     ///
-    /// Button::new("button_id", "Click me!")
+    /// Button::new("styled_button", "Styled Button")
     ///     .toggle_state(true)
-    ///     .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    ///     .selected_style(ButtonStyle::Tinted(TintColor::Accent));
     /// ```
-    /// This results in a button with a blue tinted background when selected.
     fn selected_style(mut self, style: ButtonStyle) -> Self {
         self.base = self.base.selected_style(style);
         self
@@ -265,36 +234,27 @@ impl SelectableButton for Button {
 }
 
 impl Disableable for Button {
-    /// Disables the button.
+    /// Disables the button, preventing interaction and changing its appearance.
     ///
-    /// This method allows the button to be disabled. When a button is disabled,
-    /// it doesn't react to user interactions and its appearance is updated to reflect this.
+    /// When disabled, the button's icon and label will use `Color::Disabled`.
     ///
     /// # Examples
     ///
+    /// Create a disabled button:
+    ///
     /// ```
     /// use ui::prelude::*;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .disabled(true)
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    /// Button::new("disabled_button", "Can't Click Me")
+    ///     .disabled(true);
     /// ```
-    ///
-    /// This results in a button that is disabled and does not respond to click events.
     fn disabled(mut self, disabled: bool) -> Self {
         self.base = self.base.disabled(disabled);
-        self.key_binding = self
-            .key_binding
-            .take()
-            .map(|binding| binding.disabled(disabled));
         self
     }
 }
 
 impl Clickable for Button {
-    /// Sets the click event handler for the button.
     fn on_click(
         mut self,
         handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
@@ -310,44 +270,35 @@ impl Clickable for Button {
 }
 
 impl FixedWidth for Button {
-    /// Sets a fixed width for the button.
-    ///
-    /// This function allows a button to have a fixed width instead of automatically growing or shrinking.
     /// Sets a fixed width for the button.
     ///
     /// # Examples
     ///
+    /// Create a button with a fixed width of 100 pixels:
+    ///
     /// ```
     /// use ui::prelude::*;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .width(px(100.))
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    /// Button::new("fixed_width_button", "Fixed Width")
+    ///     .width(px(100.0));
     /// ```
-    ///
-    /// This sets the button's width to be exactly 100 pixels.
     fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
         self.base = self.base.width(width);
         self
     }
 
-    /// Sets the button to occupy the full width of its container.
+    /// Makes the button take up the full width of its container.
     ///
     /// # Examples
     ///
+    /// Create a button that takes up the full width of its container:
+    ///
     /// ```
     /// use ui::prelude::*;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .full_width()
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    /// Button::new("full_width_button", "Full Width")
+    ///     .full_width();
     /// ```
-    ///
-    /// This stretches the button to the full width of its container.
     fn full_width(mut self) -> Self {
         self.base = self.base.full_width();
         self
@@ -355,43 +306,34 @@ impl FixedWidth for Button {
 }
 
 impl ButtonCommon for Button {
-    /// Sets the button's id.
     fn id(&self) -> &ElementId {
         self.base.id()
     }
 
-    /// Sets the visual style of the button using a [`ButtonStyle`].
+    /// Sets the visual style of the button.
     fn style(mut self, style: ButtonStyle) -> Self {
         self.base = self.base.style(style);
         self
     }
 
-    /// Sets the button's size using a [`ButtonSize`].
+    /// Sets the size of the button.
     fn size(mut self, size: ButtonSize) -> Self {
         self.base = self.base.size(size);
         self
     }
 
-    /// Sets a tooltip for the button.
-    ///
-    /// This method allows a tooltip to be set for the button. The tooltip is a function that
-    /// takes a mutable references to [`Window`] and [`App`], and returns an [`AnyView`]. The
-    /// tooltip is displayed when the user hovers over the button.
+    /// Sets a tooltip that appears on hover.
     ///
     /// # Examples
     ///
-    /// ```
-    /// use ui::prelude::*;
-    /// use ui::Tooltip;
+    /// Add a tooltip to a button:
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .tooltip(Tooltip::text("This is a tooltip"))
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
     /// ```
+    /// use ui::{Tooltip, prelude::*};
     ///
-    /// This will create a button with a tooltip that displays "This is a tooltip" when hovered over.
+    /// Button::new("tooltip_button", "Hover Me")
+    ///     .tooltip(Tooltip::text("This is a tooltip"));
+    /// ```
     fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
         self.base = self.base.tooltip(tooltip);
         self
@@ -436,16 +378,12 @@ impl RenderOnce for Button {
             h_flex()
                 .when(self.truncate, |this| this.min_w_0().overflow_hidden())
                 .gap(DynamicSpacing::Base04.rems(cx))
-                .when(self.icon_position == Some(IconPosition::Start), |this| {
-                    this.children(self.icon.map(|icon| {
-                        ButtonIcon::new(icon)
-                            .disabled(is_disabled)
-                            .toggle_state(is_selected)
-                            .selected_icon(self.selected_icon)
-                            .selected_icon_color(self.selected_icon_color)
-                            .size(self.icon_size)
-                            .color(self.icon_color)
-                    }))
+                .when_some(self.start_icon, |this, icon| {
+                    this.child(if is_disabled {
+                        icon.color(Color::Disabled)
+                    } else {
+                        icon
+                    })
                 })
                 .child(
                     h_flex()
@@ -465,16 +403,12 @@ impl RenderOnce for Button {
                         )
                         .children(self.key_binding),
                 )
-                .when(self.icon_position != Some(IconPosition::Start), |this| {
-                    this.children(self.icon.map(|icon| {
-                        ButtonIcon::new(icon)
-                            .disabled(is_disabled)
-                            .toggle_state(is_selected)
-                            .selected_icon(self.selected_icon)
-                            .selected_icon_color(self.selected_icon_color)
-                            .size(self.icon_size)
-                            .color(self.icon_color)
-                    }))
+                .when_some(self.end_icon, |this, icon| {
+                    this.child(if is_disabled {
+                        icon.color(Color::Disabled)
+                    } else {
+                        icon
+                    })
                 }),
         )
     }
@@ -585,24 +519,28 @@ impl Component for Button {
                         "Buttons with Icons",
                         vec![
                             single_example(
-                                "Icon Start",
-                                Button::new("icon_start", "Icon Start")
-                                    .icon(IconName::Check)
-                                    .icon_position(IconPosition::Start)
+                                "Start Icon",
+                                Button::new("icon_start", "Start Icon")
+                                    .start_icon(Icon::new(IconName::Check))
+                                    .into_any_element(),
+                            ),
+                            single_example(
+                                "End Icon",
+                                Button::new("icon_end", "End Icon")
+                                    .end_icon(Icon::new(IconName::Check))
                                     .into_any_element(),
                             ),
                             single_example(
-                                "Icon End",
-                                Button::new("icon_end", "Icon End")
-                                    .icon(IconName::Check)
-                                    .icon_position(IconPosition::End)
+                                "Both Icons",
+                                Button::new("both_icons", "Both Icons")
+                                    .start_icon(Icon::new(IconName::Check))
+                                    .end_icon(Icon::new(IconName::ChevronDown))
                                     .into_any_element(),
                             ),
                             single_example(
                                 "Icon Color",
                                 Button::new("icon_color", "Icon Color")
-                                    .icon(IconName::Check)
-                                    .icon_color(Color::Accent)
+                                    .start_icon(Icon::new(IconName::Check).color(Color::Accent))
                                     .into_any_element(),
                             ),
                         ],

crates/ui/src/components/button/button_icon.rs 🔗

@@ -1,199 +0,0 @@
-use crate::{Icon, IconName, IconSize, IconWithIndicator, Indicator, prelude::*};
-use gpui::Hsla;
-
-/// An icon that appears within a button.
-///
-/// Can be used as either an icon alongside a label, like in [`Button`](crate::Button),
-/// or as a standalone icon, like in [`IconButton`](crate::IconButton).
-#[derive(IntoElement, RegisterComponent)]
-pub(super) struct ButtonIcon {
-    icon: IconName,
-    size: IconSize,
-    color: Color,
-    disabled: bool,
-    selected: bool,
-    selected_icon: Option<IconName>,
-    selected_icon_color: Option<Color>,
-    selected_style: Option<ButtonStyle>,
-    indicator: Option<Indicator>,
-    indicator_border_color: Option<Hsla>,
-}
-
-impl ButtonIcon {
-    pub fn new(icon: IconName) -> Self {
-        Self {
-            icon,
-            size: IconSize::default(),
-            color: Color::default(),
-            disabled: false,
-            selected: false,
-            selected_icon: None,
-            selected_icon_color: None,
-            selected_style: None,
-            indicator: None,
-            indicator_border_color: None,
-        }
-    }
-
-    pub fn size(mut self, size: impl Into<Option<IconSize>>) -> Self {
-        if let Some(size) = size.into() {
-            self.size = size;
-        }
-        self
-    }
-
-    pub fn color(mut self, color: impl Into<Option<Color>>) -> Self {
-        if let Some(color) = color.into() {
-            self.color = color;
-        }
-        self
-    }
-
-    pub fn selected_icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
-        self.selected_icon = icon.into();
-        self
-    }
-
-    pub fn selected_icon_color(mut self, color: impl Into<Option<Color>>) -> Self {
-        self.selected_icon_color = color.into();
-        self
-    }
-
-    pub fn indicator(mut self, indicator: Indicator) -> Self {
-        self.indicator = Some(indicator);
-        self
-    }
-
-    pub fn indicator_border_color(mut self, color: Option<Hsla>) -> Self {
-        self.indicator_border_color = color;
-        self
-    }
-}
-
-impl Disableable for ButtonIcon {
-    fn disabled(mut self, disabled: bool) -> Self {
-        self.disabled = disabled;
-        self
-    }
-}
-
-impl Toggleable for ButtonIcon {
-    fn toggle_state(mut self, selected: bool) -> Self {
-        self.selected = selected;
-        self
-    }
-}
-
-impl SelectableButton for ButtonIcon {
-    fn selected_style(mut self, style: ButtonStyle) -> Self {
-        self.selected_style = Some(style);
-        self
-    }
-}
-
-impl RenderOnce for ButtonIcon {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let icon = self
-            .selected_icon
-            .filter(|_| self.selected)
-            .unwrap_or(self.icon);
-
-        let icon_color = if self.disabled {
-            Color::Disabled
-        } else if self.selected_style.is_some() && self.selected {
-            self.selected_style.unwrap().into()
-        } else if self.selected {
-            self.selected_icon_color.unwrap_or(Color::Selected)
-        } else {
-            self.color
-        };
-
-        let icon = Icon::new(icon).size(self.size).color(icon_color);
-
-        match self.indicator {
-            Some(indicator) => IconWithIndicator::new(icon, Some(indicator))
-                .indicator_border_color(self.indicator_border_color)
-                .into_any_element(),
-            None => icon.into_any_element(),
-        }
-    }
-}
-
-impl Component for ButtonIcon {
-    fn scope() -> ComponentScope {
-        ComponentScope::Input
-    }
-
-    fn name() -> &'static str {
-        "ButtonIcon"
-    }
-
-    fn description() -> Option<&'static str> {
-        Some("An icon component specifically designed for use within buttons.")
-    }
-
-    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
-        Some(
-            v_flex()
-                .gap_6()
-                .children(vec![
-                    example_group_with_title(
-                        "Basic Usage",
-                        vec![
-                            single_example(
-                                "Default",
-                                ButtonIcon::new(IconName::Star).into_any_element(),
-                            ),
-                            single_example(
-                                "Custom Size",
-                                ButtonIcon::new(IconName::Star)
-                                    .size(IconSize::Medium)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Custom Color",
-                                ButtonIcon::new(IconName::Star)
-                                    .color(Color::Accent)
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "States",
-                        vec![
-                            single_example(
-                                "Selected",
-                                ButtonIcon::new(IconName::Star)
-                                    .toggle_state(true)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Disabled",
-                                ButtonIcon::new(IconName::Star)
-                                    .disabled(true)
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "With Indicator",
-                        vec![
-                            single_example(
-                                "Default Indicator",
-                                ButtonIcon::new(IconName::Star)
-                                    .indicator(Indicator::dot())
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Custom Indicator",
-                                ButtonIcon::new(IconName::Star)
-                                    .indicator(Indicator::dot().color(Color::Error))
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                ])
-                .into_any_element(),
-        )
-    }
-}

crates/ui/src/components/button/icon_button.rs 🔗

@@ -1,11 +1,11 @@
 use gpui::{AnyView, DefiniteLength, Hsla};
 
 use super::button_like::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle};
-use crate::{ElevationIndex, Indicator, SelectableButton, TintColor, prelude::*};
+use crate::{
+    ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, prelude::*,
+};
 use crate::{IconName, IconSize};
 
-use super::button_icon::ButtonIcon;
-
 /// The shape of an [`IconButton`].
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 pub enum IconButtonShape {
@@ -22,6 +22,7 @@ pub struct IconButton {
     icon_color: Color,
     selected_icon: Option<IconName>,
     selected_icon_color: Option<Color>,
+    selected_style: Option<ButtonStyle>,
     indicator: Option<Indicator>,
     indicator_border_color: Option<Hsla>,
     alpha: Option<f32>,
@@ -37,6 +38,7 @@ impl IconButton {
             icon_color: Color::Default,
             selected_icon: None,
             selected_icon_color: None,
+            selected_style: None,
             indicator: None,
             indicator_border_color: None,
             alpha: None,
@@ -112,6 +114,7 @@ impl Toggleable for IconButton {
 
 impl SelectableButton for IconButton {
     fn selected_style(mut self, style: ButtonStyle) -> Self {
+        self.selected_style = Some(style);
         self.base = self.base.selected_style(style);
         self
     }
@@ -192,9 +195,25 @@ impl RenderOnce for IconButton {
     fn render(self, window: &mut Window, cx: &mut App) -> ButtonLike {
         let is_disabled = self.base.disabled;
         let is_selected = self.base.selected;
-        let selected_style = self.base.selected_style;
 
-        let color = self.icon_color.color(cx).opacity(self.alpha.unwrap_or(1.0));
+        let icon = self
+            .selected_icon
+            .filter(|_| is_selected)
+            .unwrap_or(self.icon);
+
+        let icon_color = if is_disabled {
+            Color::Disabled
+        } else if self.selected_style.is_some() && is_selected {
+            self.selected_style.unwrap().into()
+        } else if is_selected {
+            self.selected_icon_color.unwrap_or(Color::Selected)
+        } else {
+            let base_color = self.icon_color.color(cx);
+            Color::Custom(base_color.opacity(self.alpha.unwrap_or(1.0)))
+        };
+
+        let icon_element = Icon::new(icon).size(self.icon_size).color(icon_color);
+
         self.base
             .map(|this| match self.shape {
                 IconButtonShape::Square => {
@@ -203,20 +222,12 @@ impl RenderOnce for IconButton {
                 }
                 IconButtonShape::Wide => this,
             })
-            .child(
-                ButtonIcon::new(self.icon)
-                    .disabled(is_disabled)
-                    .toggle_state(is_selected)
-                    .selected_icon(self.selected_icon)
-                    .selected_icon_color(self.selected_icon_color)
-                    .when_some(selected_style, |this, style| this.selected_style(style))
-                    .when_some(self.indicator, |this, indicator| {
-                        this.indicator(indicator)
-                            .indicator_border_color(self.indicator_border_color)
-                    })
-                    .size(self.icon_size)
-                    .color(Color::Custom(color)),
-            )
+            .child(match self.indicator {
+                Some(indicator) => IconWithIndicator::new(icon_element, Some(indicator))
+                    .indicator_border_color(self.indicator_border_color)
+                    .into_any_element(),
+                None => icon_element.into_any_element(),
+            })
     }
 }
 

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

@@ -81,8 +81,7 @@ impl RenderOnce for Chip {
 
         h_flex()
             .when_some(self.height, |this, h| this.h(h))
-            .min_w_0()
-            .flex_initial()
+            .flex_none()
             .px_1()
             .border_1()
             .rounded_sm()

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

@@ -18,216 +18,9 @@ use crate::{
 };
 use itertools::intersperse_with;
 
-pub mod table_row {
-    //! A newtype for a table row that enforces a fixed column count at runtime.
-    //!
-    //! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths.
-    //! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime.
-    //! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec<T>`, without requiring const generics.
-
-    use std::{
-        any::type_name,
-        ops::{
-            Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive,
-        },
-    };
-
-    #[derive(Clone, Debug, PartialEq, Eq)]
-    pub struct TableRow<T>(Vec<T>);
-
-    impl<T> TableRow<T> {
-        pub fn from_element(element: T, length: usize) -> Self
-        where
-            T: Clone,
-        {
-            Self::from_vec(vec![element; length], length)
-        }
-
-        /// Constructs a `TableRow` from a `Vec<T>`, panicking if the length does not match `expected_length`.
-        ///
-        /// Use this when you want to ensure at construction time that the row has the correct number of columns.
-        /// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows.
-        ///
-        /// # Panics
-        /// Panics if `data.len() != expected_length`.
-        pub fn from_vec(data: Vec<T>, expected_length: usize) -> Self {
-            Self::try_from_vec(data, expected_length).unwrap_or_else(|e| {
-                let name = type_name::<Vec<T>>();
-                panic!("Expected {name} to be created successfully: {e}");
-            })
-        }
-
-        /// Attempts to construct a `TableRow` from a `Vec<T>`, returning an error if the length does not match `expected_len`.
-        ///
-        /// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully.
-        /// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise.
-        pub fn try_from_vec(data: Vec<T>, expected_len: usize) -> Result<Self, String> {
-            if data.len() != expected_len {
-                Err(format!(
-                    "Row length {} does not match expected {}",
-                    data.len(),
-                    expected_len
-                ))
-            } else {
-                Ok(Self(data))
-            }
-        }
-
-        /// Returns reference to element by column index.
-        ///
-        /// # Panics
-        /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`).
-        pub fn expect_get(&self, col: impl Into<usize>) -> &T {
-            let col = col.into();
-            self.0.get(col).unwrap_or_else(|| {
-                panic!(
-                    "Expected table row of `{}` to have {col:?}",
-                    type_name::<T>()
-                )
-            })
-        }
-
-        pub fn get(&self, col: impl Into<usize>) -> Option<&T> {
-            self.0.get(col.into())
-        }
-
-        pub fn as_slice(&self) -> &[T] {
-            &self.0
-        }
-
-        pub fn into_vec(self) -> Vec<T> {
-            self.0
-        }
-
-        /// Like [`map`], but borrows the row and clones each element before mapping.
-        ///
-        /// This is useful when you want to map over a borrowed row without consuming it,
-        /// but your mapping function requires ownership of each element.
-        ///
-        /// # Difference
-        /// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`.
-        /// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row.
-        /// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element.
-        pub fn map_cloned<F, U>(&self, f: F) -> TableRow<U>
-        where
-            F: FnMut(T) -> U,
-            T: Clone,
-        {
-            self.clone().map(f)
-        }
-
-        /// Consumes the row and transforms all elements within it in a length-safe way.
-        ///
-        /// # Difference
-        /// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element.
-        /// - Use this when you want to transform and consume the row in one step.
-        /// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references).
-        pub fn map<F, U>(self, f: F) -> TableRow<U>
-        where
-            F: FnMut(T) -> U,
-        {
-            TableRow(self.0.into_iter().map(f).collect())
-        }
-
-        /// Borrows the row and transforms all elements by reference in a length-safe way.
-        ///
-        /// # Difference
-        /// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference.
-        /// - Use this when you want to map over a borrowed row without cloning or consuming it.
-        /// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning).
-        pub fn map_ref<F, U>(&self, f: F) -> TableRow<U>
-        where
-            F: FnMut(&T) -> U,
-        {
-            TableRow(self.0.iter().map(f).collect())
-        }
-
-        /// Number of columns (alias to `len()` with more semantic meaning)
-        pub fn cols(&self) -> usize {
-            self.0.len()
-        }
-    }
-
-    ///// Convenience traits /////
-    pub trait IntoTableRow<T> {
-        fn into_table_row(self, expected_length: usize) -> TableRow<T>;
-    }
-    impl<T> IntoTableRow<T> for Vec<T> {
-        fn into_table_row(self, expected_length: usize) -> TableRow<T> {
-            TableRow::from_vec(self, expected_length)
-        }
-    }
-
-    // Index implementations for convenient access
-    impl<T> Index<usize> for TableRow<T> {
-        type Output = T;
-
-        fn index(&self, index: usize) -> &Self::Output {
-            &self.0[index]
-        }
-    }
-
-    impl<T> IndexMut<usize> for TableRow<T> {
-        fn index_mut(&mut self, index: usize) -> &mut Self::Output {
-            &mut self.0[index]
-        }
-    }
-
-    // Range indexing implementations for slice operations
-    impl<T> Index<Range<usize>> for TableRow<T> {
-        type Output = [T];
-
-        fn index(&self, index: Range<usize>) -> &Self::Output {
-            <Vec<T> as Index<Range<usize>>>::index(&self.0, index)
-        }
-    }
-
-    impl<T> Index<RangeFrom<usize>> for TableRow<T> {
-        type Output = [T];
-
-        fn index(&self, index: RangeFrom<usize>) -> &Self::Output {
-            <Vec<T> as Index<RangeFrom<usize>>>::index(&self.0, index)
-        }
-    }
-
-    impl<T> Index<RangeTo<usize>> for TableRow<T> {
-        type Output = [T];
-
-        fn index(&self, index: RangeTo<usize>) -> &Self::Output {
-            <Vec<T> as Index<RangeTo<usize>>>::index(&self.0, index)
-        }
-    }
-
-    impl<T> Index<RangeToInclusive<usize>> for TableRow<T> {
-        type Output = [T];
-
-        fn index(&self, index: RangeToInclusive<usize>) -> &Self::Output {
-            <Vec<T> as Index<RangeToInclusive<usize>>>::index(&self.0, index)
-        }
-    }
-
-    impl<T> Index<RangeFull> for TableRow<T> {
-        type Output = [T];
-
-        fn index(&self, index: RangeFull) -> &Self::Output {
-            <Vec<T> as Index<RangeFull>>::index(&self.0, index)
-        }
-    }
-
-    impl<T> Index<RangeInclusive<usize>> for TableRow<T> {
-        type Output = [T];
-
-        fn index(&self, index: RangeInclusive<usize>) -> &Self::Output {
-            <Vec<T> as Index<RangeInclusive<usize>>>::index(&self.0, index)
-        }
-    }
-
-    impl<T> IndexMut<RangeInclusive<usize>> for TableRow<T> {
-        fn index_mut(&mut self, index: RangeInclusive<usize>) -> &mut Self::Output {
-            <Vec<T> as IndexMut<RangeInclusive<usize>>>::index_mut(&mut self.0, index)
-        }
-    }
-}
+pub mod table_row;
+#[cfg(test)]
+mod tests;
 
 const RESIZE_COLUMN_WIDTH: f32 = 8.0;
 
@@ -1445,330 +1238,3 @@ impl Component for Table {
         )
     }
 }
-
-#[cfg(test)]
-mod test {
-    use super::*;
-
-    fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
-        a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
-    }
-
-    fn cols_to_str(cols: &[f32], total_size: f32) -> String {
-        cols.iter()
-            .map(|f| "*".repeat(f32::round(f * total_size) as usize))
-            .collect::<Vec<String>>()
-            .join("|")
-    }
-
-    fn parse_resize_behavior(
-        input: &str,
-        total_size: f32,
-        expected_cols: usize,
-    ) -> Vec<TableResizeBehavior> {
-        let mut resize_behavior = Vec::with_capacity(expected_cols);
-        for col in input.split('|') {
-            if col.starts_with('X') || col.is_empty() {
-                resize_behavior.push(TableResizeBehavior::None);
-            } else if col.starts_with('*') {
-                resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size));
-            } else {
-                panic!("invalid test input: unrecognized resize behavior: {}", col);
-            }
-        }
-
-        if resize_behavior.len() != expected_cols {
-            panic!(
-                "invalid test input: expected {} columns, got {}",
-                expected_cols,
-                resize_behavior.len()
-            );
-        }
-        resize_behavior
-    }
-
-    mod reset_column_size {
-        use super::*;
-
-        fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
-            let mut widths = Vec::new();
-            let mut column_index = None;
-            for (index, col) in input.split('|').enumerate() {
-                widths.push(col.len() as f32);
-                if col.starts_with('X') {
-                    column_index = Some(index);
-                }
-            }
-
-            for w in &widths {
-                assert!(w.is_finite(), "incorrect number of columns");
-            }
-            let total = widths.iter().sum::<f32>();
-            for width in &mut widths {
-                *width /= total;
-            }
-            (widths, total, column_index)
-        }
-
-        #[track_caller]
-        fn check_reset_size(
-            initial_sizes: &str,
-            widths: &str,
-            expected: &str,
-            resize_behavior: &str,
-        ) {
-            let (initial_sizes, total_1, None) = parse(initial_sizes) else {
-                panic!("invalid test input: initial sizes should not be marked");
-            };
-            let (widths, total_2, Some(column_index)) = parse(widths) else {
-                panic!("invalid test input: widths should be marked");
-            };
-            assert_eq!(
-                total_1, total_2,
-                "invalid test input: total width not the same {total_1}, {total_2}"
-            );
-            let (expected, total_3, None) = parse(expected) else {
-                panic!("invalid test input: expected should not be marked: {expected:?}");
-            };
-            assert_eq!(
-                total_2, total_3,
-                "invalid test input: total width not the same"
-            );
-            let cols = initial_sizes.len();
-            let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
-            let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
-            let result = TableColumnWidths::reset_to_initial_size(
-                column_index,
-                TableRow::from_vec(widths, cols),
-                TableRow::from_vec(initial_sizes, cols),
-                &resize_behavior,
-            );
-            let result_slice = result.as_slice();
-            let is_eq = is_almost_eq(result_slice, &expected);
-            if !is_eq {
-                let result_str = cols_to_str(result_slice, total_1);
-                let expected_str = cols_to_str(&expected, total_1);
-                panic!(
-                    "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
-                );
-            }
-        }
-
-        macro_rules! check_reset_size {
-            (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
-                check_reset_size($initial, $current, $expected, $resizing);
-            };
-            ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
-                #[test]
-                fn $name() {
-                    check_reset_size($initial, $current, $expected, $resizing);
-                }
-            };
-        }
-
-        check_reset_size!(
-            basic_right,
-            columns: 5,
-            starting: "**|**|**|**|**",
-            snapshot: "**|**|X|***|**",
-            expected: "**|**|**|**|**",
-            minimums: "X|*|*|*|*",
-        );
-
-        check_reset_size!(
-            basic_left,
-            columns: 5,
-            starting: "**|**|**|**|**",
-            snapshot: "**|**|***|X|**",
-            expected: "**|**|**|**|**",
-            minimums: "X|*|*|*|**",
-        );
-
-        check_reset_size!(
-            squashed_left_reset_col2,
-            columns: 6,
-            starting: "*|***|**|**|****|*",
-            snapshot: "*|*|X|*|*|********",
-            expected: "*|*|**|*|*|*******",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            grow_cascading_right,
-            columns: 6,
-            starting: "*|***|****|**|***|*",
-            snapshot: "*|***|X|**|**|*****",
-            expected: "*|***|****|*|*|****",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-           squashed_right_reset_col4,
-           columns: 6,
-           starting: "*|***|**|**|****|*",
-           snapshot: "*|********|*|*|X|*",
-           expected: "*|*****|*|*|****|*",
-           minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            reset_col6_right,
-            columns: 6,
-            starting: "*|***|**|***|***|**",
-            snapshot: "*|***|**|***|**|XXX",
-            expected: "*|***|**|***|***|**",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            reset_col6_left,
-            columns: 6,
-            starting: "*|***|**|***|***|**",
-            snapshot: "*|***|**|***|****|X",
-            expected: "*|***|**|***|***|**",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            last_column_grow_cascading,
-            columns: 6,
-            starting: "*|***|**|**|**|***",
-            snapshot: "*|*******|*|**|*|X",
-            expected: "*|******|*|*|*|***",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            goes_left_when_left_has_extreme_diff,
-            columns: 6,
-            starting: "*|***|****|**|**|***",
-            snapshot: "*|********|X|*|**|**",
-            expected: "*|*****|****|*|**|**",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            basic_shrink_right,
-            columns: 6,
-            starting: "**|**|**|**|**|**",
-            snapshot: "**|**|XXX|*|**|**",
-            expected: "**|**|**|**|**|**",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            shrink_should_go_left,
-            columns: 6,
-            starting: "*|***|**|*|*|*",
-            snapshot: "*|*|XXX|**|*|*",
-            expected: "*|**|**|**|*|*",
-            minimums: "X|*|*|*|*|*",
-        );
-
-        check_reset_size!(
-            shrink_should_go_right,
-            columns: 6,
-            starting: "*|***|**|**|**|*",
-            snapshot: "*|****|XXX|*|*|*",
-            expected: "*|****|**|**|*|*",
-            minimums: "X|*|*|*|*|*",
-        );
-    }
-
-    mod drag_handle {
-        use super::*;
-
-        fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
-            let mut widths = Vec::new();
-            let column_index = input.replace("*", "").find("I");
-            for col in input.replace("I", "|").split('|') {
-                widths.push(col.len() as f32);
-            }
-
-            for w in &widths {
-                assert!(w.is_finite(), "incorrect number of columns");
-            }
-            let total = widths.iter().sum::<f32>();
-            for width in &mut widths {
-                *width /= total;
-            }
-            (widths, total, column_index)
-        }
-
-        #[track_caller]
-        fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) {
-            let (widths, total_1, Some(column_index)) = parse(widths) else {
-                panic!("invalid test input: widths should be marked");
-            };
-            let (expected, total_2, None) = parse(expected) else {
-                panic!("invalid test input: expected should not be marked: {expected:?}");
-            };
-            assert_eq!(
-                total_1, total_2,
-                "invalid test input: total width not the same"
-            );
-            let cols = widths.len();
-            let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
-            let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
-
-            let distance = distance as f32 / total_1;
-
-            let mut widths_table_row = TableRow::from_vec(widths, cols);
-            TableColumnWidths::drag_column_handle(
-                distance,
-                column_index,
-                &mut widths_table_row,
-                &resize_behavior,
-            );
-
-            let result_widths = widths_table_row.as_slice();
-            let is_eq = is_almost_eq(result_widths, &expected);
-            if !is_eq {
-                let result_str = cols_to_str(result_widths, total_1);
-                let expected_str = cols_to_str(&expected, total_1);
-                panic!(
-                    "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
-                );
-            }
-        }
-
-        macro_rules! check {
-            (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
-                check($dist, $current, $expected, $resizing);
-            };
-            ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
-                #[test]
-                fn $name() {
-                    check($dist, $current, $expected, $resizing);
-                }
-            };
-        }
-
-        check!(
-            basic_right_drag,
-            columns: 3,
-            distance: 1,
-            snapshot: "**|**I**",
-            expected: "**|***|*",
-            minimums: "X|*|*",
-        );
-
-        check!(
-            drag_left_against_mins,
-            columns: 5,
-            distance: -1,
-            snapshot: "*|*|*|*I*******",
-            expected: "*|*|*|*|*******",
-            minimums: "X|*|*|*|*",
-        );
-
-        check!(
-            drag_left,
-            columns: 5,
-            distance: -2,
-            snapshot: "*|*|*|*****I***",
-            expected: "*|*|*|***|*****",
-            minimums: "X|*|*|*|*",
-        );
-    }
-}

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

@@ -0,0 +1,208 @@
+//! A newtype for a table row that enforces a fixed column count at runtime.
+//!
+//! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths.
+//! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime.
+//! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec<T>`, without requiring const generics.
+
+use std::{
+    any::type_name,
+    ops::{
+        Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive,
+    },
+};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct TableRow<T>(Vec<T>);
+
+impl<T> TableRow<T> {
+    pub fn from_element(element: T, length: usize) -> Self
+    where
+        T: Clone,
+    {
+        Self::from_vec(vec![element; length], length)
+    }
+
+    /// Constructs a `TableRow` from a `Vec<T>`, panicking if the length does not match `expected_length`.
+    ///
+    /// Use this when you want to ensure at construction time that the row has the correct number of columns.
+    /// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows.
+    ///
+    /// # Panics
+    /// Panics if `data.len() != expected_length`.
+    pub fn from_vec(data: Vec<T>, expected_length: usize) -> Self {
+        Self::try_from_vec(data, expected_length).unwrap_or_else(|e| {
+            let name = type_name::<Vec<T>>();
+            panic!("Expected {name} to be created successfully: {e}");
+        })
+    }
+
+    /// Attempts to construct a `TableRow` from a `Vec<T>`, returning an error if the length does not match `expected_len`.
+    ///
+    /// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully.
+    /// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise.
+    pub fn try_from_vec(data: Vec<T>, expected_len: usize) -> Result<Self, String> {
+        if data.len() != expected_len {
+            Err(format!(
+                "Row length {} does not match expected {}",
+                data.len(),
+                expected_len
+            ))
+        } else {
+            Ok(Self(data))
+        }
+    }
+
+    /// Returns reference to element by column index.
+    ///
+    /// # Panics
+    /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`).
+    pub fn expect_get(&self, col: impl Into<usize>) -> &T {
+        let col = col.into();
+        self.0.get(col).unwrap_or_else(|| {
+            panic!(
+                "Expected table row of `{}` to have {col:?}",
+                type_name::<T>()
+            )
+        })
+    }
+
+    pub fn get(&self, col: impl Into<usize>) -> Option<&T> {
+        self.0.get(col.into())
+    }
+
+    pub fn as_slice(&self) -> &[T] {
+        &self.0
+    }
+
+    pub fn into_vec(self) -> Vec<T> {
+        self.0
+    }
+
+    /// Like [`map`], but borrows the row and clones each element before mapping.
+    ///
+    /// This is useful when you want to map over a borrowed row without consuming it,
+    /// but your mapping function requires ownership of each element.
+    ///
+    /// # Difference
+    /// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`.
+    /// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row.
+    /// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element.
+    pub fn map_cloned<F, U>(&self, f: F) -> TableRow<U>
+    where
+        F: FnMut(T) -> U,
+        T: Clone,
+    {
+        self.clone().map(f)
+    }
+
+    /// Consumes the row and transforms all elements within it in a length-safe way.
+    ///
+    /// # Difference
+    /// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element.
+    /// - Use this when you want to transform and consume the row in one step.
+    /// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references).
+    pub fn map<F, U>(self, f: F) -> TableRow<U>
+    where
+        F: FnMut(T) -> U,
+    {
+        TableRow(self.0.into_iter().map(f).collect())
+    }
+
+    /// Borrows the row and transforms all elements by reference in a length-safe way.
+    ///
+    /// # Difference
+    /// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference.
+    /// - Use this when you want to map over a borrowed row without cloning or consuming it.
+    /// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning).
+    pub fn map_ref<F, U>(&self, f: F) -> TableRow<U>
+    where
+        F: FnMut(&T) -> U,
+    {
+        TableRow(self.0.iter().map(f).collect())
+    }
+
+    /// Number of columns (alias to `len()` with more semantic meaning)
+    pub fn cols(&self) -> usize {
+        self.0.len()
+    }
+}
+
+///// Convenience traits /////
+pub trait IntoTableRow<T> {
+    fn into_table_row(self, expected_length: usize) -> TableRow<T>;
+}
+impl<T> IntoTableRow<T> for Vec<T> {
+    fn into_table_row(self, expected_length: usize) -> TableRow<T> {
+        TableRow::from_vec(self, expected_length)
+    }
+}
+
+// Index implementations for convenient access
+impl<T> Index<usize> for TableRow<T> {
+    type Output = T;
+
+    fn index(&self, index: usize) -> &Self::Output {
+        &self.0[index]
+    }
+}
+
+impl<T> IndexMut<usize> for TableRow<T> {
+    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
+        &mut self.0[index]
+    }
+}
+
+// Range indexing implementations for slice operations
+impl<T> Index<Range<usize>> for TableRow<T> {
+    type Output = [T];
+
+    fn index(&self, index: Range<usize>) -> &Self::Output {
+        <Vec<T> as Index<Range<usize>>>::index(&self.0, index)
+    }
+}
+
+impl<T> Index<RangeFrom<usize>> for TableRow<T> {
+    type Output = [T];
+
+    fn index(&self, index: RangeFrom<usize>) -> &Self::Output {
+        <Vec<T> as Index<RangeFrom<usize>>>::index(&self.0, index)
+    }
+}
+
+impl<T> Index<RangeTo<usize>> for TableRow<T> {
+    type Output = [T];
+
+    fn index(&self, index: RangeTo<usize>) -> &Self::Output {
+        <Vec<T> as Index<RangeTo<usize>>>::index(&self.0, index)
+    }
+}
+
+impl<T> Index<RangeToInclusive<usize>> for TableRow<T> {
+    type Output = [T];
+
+    fn index(&self, index: RangeToInclusive<usize>) -> &Self::Output {
+        <Vec<T> as Index<RangeToInclusive<usize>>>::index(&self.0, index)
+    }
+}
+
+impl<T> Index<RangeFull> for TableRow<T> {
+    type Output = [T];
+
+    fn index(&self, index: RangeFull) -> &Self::Output {
+        <Vec<T> as Index<RangeFull>>::index(&self.0, index)
+    }
+}
+
+impl<T> Index<RangeInclusive<usize>> for TableRow<T> {
+    type Output = [T];
+
+    fn index(&self, index: RangeInclusive<usize>) -> &Self::Output {
+        <Vec<T> as Index<RangeInclusive<usize>>>::index(&self.0, index)
+    }
+}
+
+impl<T> IndexMut<RangeInclusive<usize>> for TableRow<T> {
+    fn index_mut(&mut self, index: RangeInclusive<usize>) -> &mut Self::Output {
+        <Vec<T> as IndexMut<RangeInclusive<usize>>>::index_mut(&mut self.0, index)
+    }
+}

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

@@ -0,0 +1,318 @@
+use super::*;
+
+fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
+    a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
+}
+
+fn cols_to_str(cols: &[f32], total_size: f32) -> String {
+    cols.iter()
+        .map(|f| "*".repeat(f32::round(f * total_size) as usize))
+        .collect::<Vec<String>>()
+        .join("|")
+}
+
+fn parse_resize_behavior(
+    input: &str,
+    total_size: f32,
+    expected_cols: usize,
+) -> Vec<TableResizeBehavior> {
+    let mut resize_behavior = Vec::with_capacity(expected_cols);
+    for col in input.split('|') {
+        if col.starts_with('X') || col.is_empty() {
+            resize_behavior.push(TableResizeBehavior::None);
+        } else if col.starts_with('*') {
+            resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size));
+        } else {
+            panic!("invalid test input: unrecognized resize behavior: {}", col);
+        }
+    }
+
+    if resize_behavior.len() != expected_cols {
+        panic!(
+            "invalid test input: expected {} columns, got {}",
+            expected_cols,
+            resize_behavior.len()
+        );
+    }
+    resize_behavior
+}
+
+mod reset_column_size {
+    use super::*;
+
+    fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
+        let mut widths = Vec::new();
+        let mut column_index = None;
+        for (index, col) in input.split('|').enumerate() {
+            widths.push(col.len() as f32);
+            if col.starts_with('X') {
+                column_index = Some(index);
+            }
+        }
+
+        for w in &widths {
+            assert!(w.is_finite(), "incorrect number of columns");
+        }
+        let total = widths.iter().sum::<f32>();
+        for width in &mut widths {
+            *width /= total;
+        }
+        (widths, total, column_index)
+    }
+
+    #[track_caller]
+    fn check_reset_size(initial_sizes: &str, widths: &str, expected: &str, resize_behavior: &str) {
+        let (initial_sizes, total_1, None) = parse(initial_sizes) else {
+            panic!("invalid test input: initial sizes should not be marked");
+        };
+        let (widths, total_2, Some(column_index)) = parse(widths) else {
+            panic!("invalid test input: widths should be marked");
+        };
+        assert_eq!(
+            total_1, total_2,
+            "invalid test input: total width not the same {total_1}, {total_2}"
+        );
+        let (expected, total_3, None) = parse(expected) else {
+            panic!("invalid test input: expected should not be marked: {expected:?}");
+        };
+        assert_eq!(
+            total_2, total_3,
+            "invalid test input: total width not the same"
+        );
+        let cols = initial_sizes.len();
+        let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
+        let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
+        let result = TableColumnWidths::reset_to_initial_size(
+            column_index,
+            TableRow::from_vec(widths, cols),
+            TableRow::from_vec(initial_sizes, cols),
+            &resize_behavior,
+        );
+        let result_slice = result.as_slice();
+        let is_eq = is_almost_eq(result_slice, &expected);
+        if !is_eq {
+            let result_str = cols_to_str(result_slice, total_1);
+            let expected_str = cols_to_str(&expected, total_1);
+            panic!(
+                "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
+            );
+        }
+    }
+
+    macro_rules! check_reset_size {
+        (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
+            check_reset_size($initial, $current, $expected, $resizing);
+        };
+        ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
+            #[test]
+            fn $name() {
+                check_reset_size($initial, $current, $expected, $resizing);
+            }
+        };
+    }
+
+    check_reset_size!(
+        basic_right,
+        columns: 5,
+        starting: "**|**|**|**|**",
+        snapshot: "**|**|X|***|**",
+        expected: "**|**|**|**|**",
+        minimums: "X|*|*|*|*",
+    );
+
+    check_reset_size!(
+        basic_left,
+        columns: 5,
+        starting: "**|**|**|**|**",
+        snapshot: "**|**|***|X|**",
+        expected: "**|**|**|**|**",
+        minimums: "X|*|*|*|**",
+    );
+
+    check_reset_size!(
+        squashed_left_reset_col2,
+        columns: 6,
+        starting: "*|***|**|**|****|*",
+        snapshot: "*|*|X|*|*|********",
+        expected: "*|*|**|*|*|*******",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        grow_cascading_right,
+        columns: 6,
+        starting: "*|***|****|**|***|*",
+        snapshot: "*|***|X|**|**|*****",
+        expected: "*|***|****|*|*|****",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+       squashed_right_reset_col4,
+       columns: 6,
+       starting: "*|***|**|**|****|*",
+       snapshot: "*|********|*|*|X|*",
+       expected: "*|*****|*|*|****|*",
+       minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        reset_col6_right,
+        columns: 6,
+        starting: "*|***|**|***|***|**",
+        snapshot: "*|***|**|***|**|XXX",
+        expected: "*|***|**|***|***|**",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        reset_col6_left,
+        columns: 6,
+        starting: "*|***|**|***|***|**",
+        snapshot: "*|***|**|***|****|X",
+        expected: "*|***|**|***|***|**",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        last_column_grow_cascading,
+        columns: 6,
+        starting: "*|***|**|**|**|***",
+        snapshot: "*|*******|*|**|*|X",
+        expected: "*|******|*|*|*|***",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        goes_left_when_left_has_extreme_diff,
+        columns: 6,
+        starting: "*|***|****|**|**|***",
+        snapshot: "*|********|X|*|**|**",
+        expected: "*|*****|****|*|**|**",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        basic_shrink_right,
+        columns: 6,
+        starting: "**|**|**|**|**|**",
+        snapshot: "**|**|XXX|*|**|**",
+        expected: "**|**|**|**|**|**",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        shrink_should_go_left,
+        columns: 6,
+        starting: "*|***|**|*|*|*",
+        snapshot: "*|*|XXX|**|*|*",
+        expected: "*|**|**|**|*|*",
+        minimums: "X|*|*|*|*|*",
+    );
+
+    check_reset_size!(
+        shrink_should_go_right,
+        columns: 6,
+        starting: "*|***|**|**|**|*",
+        snapshot: "*|****|XXX|*|*|*",
+        expected: "*|****|**|**|*|*",
+        minimums: "X|*|*|*|*|*",
+    );
+}
+
+mod drag_handle {
+    use super::*;
+
+    fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
+        let mut widths = Vec::new();
+        let column_index = input.replace("*", "").find("I");
+        for col in input.replace("I", "|").split('|') {
+            widths.push(col.len() as f32);
+        }
+
+        for w in &widths {
+            assert!(w.is_finite(), "incorrect number of columns");
+        }
+        let total = widths.iter().sum::<f32>();
+        for width in &mut widths {
+            *width /= total;
+        }
+        (widths, total, column_index)
+    }
+
+    #[track_caller]
+    fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) {
+        let (widths, total_1, Some(column_index)) = parse(widths) else {
+            panic!("invalid test input: widths should be marked");
+        };
+        let (expected, total_2, None) = parse(expected) else {
+            panic!("invalid test input: expected should not be marked: {expected:?}");
+        };
+        assert_eq!(
+            total_1, total_2,
+            "invalid test input: total width not the same"
+        );
+        let cols = widths.len();
+        let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
+        let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
+
+        let distance = distance as f32 / total_1;
+
+        let mut widths_table_row = TableRow::from_vec(widths, cols);
+        TableColumnWidths::drag_column_handle(
+            distance,
+            column_index,
+            &mut widths_table_row,
+            &resize_behavior,
+        );
+
+        let result_widths = widths_table_row.as_slice();
+        let is_eq = is_almost_eq(result_widths, &expected);
+        if !is_eq {
+            let result_str = cols_to_str(result_widths, total_1);
+            let expected_str = cols_to_str(&expected, total_1);
+            panic!(
+                "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
+            );
+        }
+    }
+
+    macro_rules! check {
+        (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
+            check($dist, $current, $expected, $resizing);
+        };
+        ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
+            #[test]
+            fn $name() {
+                check($dist, $current, $expected, $resizing);
+            }
+        };
+    }
+
+    check!(
+        basic_right_drag,
+        columns: 3,
+        distance: 1,
+        snapshot: "**|**I**",
+        expected: "**|***|*",
+        minimums: "X|*|*",
+    );
+
+    check!(
+        drag_left_against_mins,
+        columns: 5,
+        distance: -1,
+        snapshot: "*|*|*|*I*******",
+        expected: "*|*|*|*|*******",
+        minimums: "X|*|*|*|*",
+    );
+
+    check!(
+        drag_left,
+        columns: 5,
+        distance: -2,
+        snapshot: "*|*|*|*****I***",
+        expected: "*|*|*|***|*****",
+        minimums: "X|*|*|*|*",
+    );
+}

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

@@ -1,3 +1,4 @@
+use crate::Tooltip;
 use crate::prelude::*;
 
 #[derive(IntoElement, RegisterComponent)]
@@ -6,6 +7,7 @@ pub struct DiffStat {
     added: usize,
     removed: usize,
     label_size: LabelSize,
+    tooltip: Option<SharedString>,
 }
 
 impl DiffStat {
@@ -15,6 +17,7 @@ impl DiffStat {
             added,
             removed,
             label_size: LabelSize::Small,
+            tooltip: None,
         }
     }
 
@@ -22,41 +25,32 @@ impl DiffStat {
         self.label_size = label_size;
         self
     }
+
+    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
+        self.tooltip = Some(tooltip.into());
+        self
+    }
 }
 
 impl RenderOnce for DiffStat {
     fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let tooltip = self.tooltip;
         h_flex()
             .id(self.id)
             .gap_1()
             .child(
-                h_flex()
-                    .gap_0p5()
-                    .child(
-                        Icon::new(IconName::Plus)
-                            .size(IconSize::XSmall)
-                            .color(Color::Success),
-                    )
-                    .child(
-                        Label::new(self.added.to_string())
-                            .color(Color::Success)
-                            .size(self.label_size),
-                    ),
+                Label::new(format!("+\u{2009}{}", self.added))
+                    .color(Color::Success)
+                    .size(self.label_size),
             )
             .child(
-                h_flex()
-                    .gap_0p5()
-                    .child(
-                        Icon::new(IconName::Dash)
-                            .size(IconSize::XSmall)
-                            .color(Color::Error),
-                    )
-                    .child(
-                        Label::new(self.removed.to_string())
-                            .color(Color::Error)
-                            .size(self.label_size),
-                    ),
+                Label::new(format!("\u{2012}\u{2009}{}", self.removed))
+                    .color(Color::Error)
+                    .size(self.label_size),
             )
+            .when_some(tooltip, |this, tooltip| {
+                this.tooltip(Tooltip::text(tooltip))
+            })
     }
 }
 

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

@@ -163,11 +163,10 @@ impl RenderOnce for DropdownMenu {
                 Some(
                     Button::new(self.id.clone(), text)
                         .style(button_style)
-                        .when(self.chevron, |this| {
-                            this.icon(self.trigger_icon)
-                                .icon_position(IconPosition::End)
-                                .icon_size(IconSize::XSmall)
-                                .icon_color(Color::Muted)
+                        .when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| {
+                            this.end_icon(
+                                Icon::new(icon).size(IconSize::XSmall).color(Color::Muted),
+                            )
                         })
                         .when(full_width, |this| this.full_width())
                         .size(trigger_size)

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

@@ -0,0 +1,88 @@
+use gpui::{Hsla, Pixels, SharedString, linear_color_stop, linear_gradient, px};
+
+use crate::prelude::*;
+
+/// A gradient overlay that fades from a solid color to transparent.
+#[derive(IntoElement)]
+pub struct GradientFade {
+    base_bg: Hsla,
+    hover_bg: Hsla,
+    active_bg: Hsla,
+    width: Pixels,
+    right: Pixels,
+    gradient_stop: f32,
+    group_name: Option<SharedString>,
+}
+
+impl GradientFade {
+    pub fn new(base_bg: Hsla, hover_bg: Hsla, active_bg: Hsla) -> Self {
+        Self {
+            base_bg,
+            hover_bg,
+            active_bg,
+            width: px(48.0),
+            right: px(0.0),
+            gradient_stop: 0.6,
+            group_name: None,
+        }
+    }
+
+    pub fn width(mut self, width: Pixels) -> Self {
+        self.width = width;
+        self
+    }
+
+    pub fn right(mut self, right: Pixels) -> Self {
+        self.right = right;
+        self
+    }
+
+    pub fn gradient_stop(mut self, stop: f32) -> Self {
+        self.gradient_stop = stop;
+        self
+    }
+
+    pub fn group_name(mut self, name: impl Into<SharedString>) -> Self {
+        self.group_name = Some(name.into());
+        self
+    }
+}
+
+impl RenderOnce for GradientFade {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let stop = self.gradient_stop;
+        let hover_bg = self.hover_bg;
+        let active_bg = self.active_bg;
+
+        div()
+            .id("gradient_fade")
+            .absolute()
+            .top_0()
+            .right(self.right)
+            .w(self.width)
+            .h_full()
+            .bg(linear_gradient(
+                90.,
+                linear_color_stop(self.base_bg, stop),
+                linear_color_stop(self.base_bg.opacity(0.0), 0.),
+            ))
+            .when_some(self.group_name.clone(), |element, group_name| {
+                element.group_hover(group_name, move |s| {
+                    s.bg(linear_gradient(
+                        90.,
+                        linear_color_stop(hover_bg, stop),
+                        linear_color_stop(hover_bg.opacity(0.0), 0.),
+                    ))
+                })
+            })
+            .when_some(self.group_name, |element, group_name| {
+                element.group_active(group_name, move |s| {
+                    s.bg(linear_gradient(
+                        90.,
+                        linear_color_stop(active_bg, stop),
+                        linear_color_stop(active_bg.opacity(0.0), 0.),
+                    ))
+                })
+            })
+    }
+}

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

@@ -73,6 +73,34 @@ impl Label {
     gpui::margin_style_methods!({
         visibility: pub
     });
+
+    pub fn flex_1(mut self) -> Self {
+        self.style().flex_grow = Some(1.);
+        self.style().flex_shrink = Some(1.);
+        self.style().flex_basis = Some(gpui::relative(0.).into());
+        self
+    }
+
+    pub fn flex_none(mut self) -> Self {
+        self.style().flex_grow = Some(0.);
+        self.style().flex_shrink = Some(0.);
+        self
+    }
+
+    pub fn flex_grow(mut self) -> Self {
+        self.style().flex_grow = Some(1.);
+        self
+    }
+
+    pub fn flex_shrink(mut self) -> Self {
+        self.style().flex_shrink = Some(1.);
+        self
+    }
+
+    pub fn flex_shrink_0(mut self) -> Self {
+        self.style().flex_shrink = Some(0.);
+        self
+    }
 }
 
 impl LabelCommon for Label {

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

@@ -4,7 +4,7 @@ use component::{Component, ComponentScope, example_group_with_title, single_exam
 use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px};
 use smallvec::SmallVec;
 
-use crate::{Disclosure, prelude::*};
+use crate::{Disclosure, GradientFade, prelude::*};
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
 pub enum ListItemSpacing {
@@ -31,6 +31,9 @@ pub struct ListItem {
     /// A slot for content that appears on hover after the children
     /// It will obscure the `end_slot` when visible.
     end_hover_slot: Option<AnyElement>,
+    /// When true, renders a gradient fade overlay before the `end_hover_slot`
+    /// to smoothly truncate overflowing content.
+    end_hover_gradient_overlay: bool,
     toggle: Option<bool>,
     inset: bool,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -45,6 +48,7 @@ pub struct ListItem {
     rounded: bool,
     overflow_x: bool,
     focused: Option<bool>,
+    docked_right: bool,
 }
 
 impl ListItem {
@@ -60,6 +64,7 @@ impl ListItem {
             start_slot: None,
             end_slot: None,
             end_hover_slot: None,
+            end_hover_gradient_overlay: false,
             toggle: None,
             inset: false,
             on_click: None,
@@ -74,6 +79,7 @@ impl ListItem {
             rounded: false,
             overflow_x: false,
             focused: None,
+            docked_right: false,
         }
     }
 
@@ -166,6 +172,11 @@ impl ListItem {
         self
     }
 
+    pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self {
+        self.end_hover_gradient_overlay = show;
+        self
+    }
+
     pub fn outlined(mut self) -> Self {
         self.outlined = true;
         self
@@ -185,6 +196,11 @@ impl ListItem {
         self.focused = Some(focused);
         self
     }
+
+    pub fn docked_right(mut self, docked_right: bool) -> Self {
+        self.docked_right = docked_right;
+        self
+    }
 }
 
 impl Disableable for ListItem {
@@ -209,6 +225,21 @@ impl ParentElement for ListItem {
 
 impl RenderOnce for ListItem {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let color = cx.theme().colors();
+
+        let base_bg = if self.selected {
+            color.element_active
+        } else {
+            color.panel_background
+        };
+
+        let end_hover_gradient_overlay =
+            GradientFade::new(base_bg, color.element_hover, color.element_active)
+                .width(px(96.0))
+                .when_some(self.group_name.clone(), |fade, group| {
+                    fade.group_name(group)
+                });
+
         h_flex()
             .id(self.id)
             .when_some(self.group_name, |this, group| this.group(group))
@@ -220,25 +251,23 @@ impl RenderOnce for ListItem {
                     .px(DynamicSpacing::Base04.rems(cx))
             })
             .when(!self.inset && !self.disabled, |this| {
-                this
-                    // TODO: Add focus state
-                    // .when(self.state == InteractionState::Focused, |this| {
-                    .when_some(self.focused, |this, focused| {
-                        if focused {
-                            this.border_1()
-                                .border_color(cx.theme().colors().border_focused)
-                        } else {
-                            this.border_1()
-                        }
-                    })
-                    .when(self.selectable, |this| {
-                        this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
-                            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
-                            .when(self.outlined, |this| this.rounded_sm())
-                            .when(self.selected, |this| {
-                                this.bg(cx.theme().colors().ghost_element_selected)
-                            })
-                    })
+                this.when_some(self.focused, |this, focused| {
+                    if focused {
+                        this.border_1()
+                            .when(self.docked_right, |this| this.border_r_2())
+                            .border_color(cx.theme().colors().border_focused)
+                    } else {
+                        this.border_1()
+                    }
+                })
+                .when(self.selectable, |this| {
+                    this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
+                        .active(|style| style.bg(cx.theme().colors().ghost_element_active))
+                        .when(self.outlined, |this| this.rounded_sm())
+                        .when(self.selected, |this| {
+                            this.bg(cx.theme().colors().ghost_element_selected)
+                        })
+                })
             })
             .when(self.rounded, |this| this.rounded_sm())
             .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover))
@@ -350,6 +379,9 @@ impl RenderOnce for ListItem {
                                 .right(DynamicSpacing::Base06.rems(cx))
                                 .top_0()
                                 .visible_on_hover("list_item")
+                                .when(self.end_hover_gradient_overlay, |this| {
+                                    this.child(end_hover_gradient_overlay)
+                                })
                                 .child(end_hover_slot),
                         )
                     }),

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

@@ -1041,7 +1041,18 @@ impl ScrollbarLayout {
 
 impl PartialEq for ScrollbarLayout {
     fn eq(&self, other: &Self) -> bool {
-        self.axis == other.axis && self.thumb_bounds == other.thumb_bounds
+        if self.axis != other.axis {
+            return false;
+        }
+
+        let axis = self.axis;
+        let thumb_offset =
+            self.thumb_bounds.origin.along(axis) - self.track_bounds.origin.along(axis);
+        let other_thumb_offset =
+            other.thumb_bounds.origin.along(axis) - other.track_bounds.origin.along(axis);
+
+        thumb_offset == other_thumb_offset
+            && self.thumb_bounds.size.along(axis) == other.thumb_bounds.size.along(axis)
     }
 }
 

crates/util/Cargo.toml 🔗

@@ -64,7 +64,6 @@ tendril = "0.4.3"
 
 [dev-dependencies]
 git2.workspace = true
-indoc.workspace = true
 rand.workspace = true
 util_macros.workspace = true
 pretty_assertions.workspace = true

crates/util/src/paths.rs 🔗

@@ -601,6 +601,7 @@ const ROW_COL_CAPTURE_REGEX: &str = r"(?xs)
         |
         \((\d+)\)()     # filename(row)
     )
+    \:*$
     |
     (.+?)(?:
         \:+(\d+)\:(\d+)\:*$  # filename:row:column
@@ -2097,6 +2098,15 @@ mod tests {
                 column: Some(9),
             }
         );
+
+        assert_eq!(
+            PathWithPosition::parse_str("main (1).log"),
+            PathWithPosition {
+                path: PathBuf::from("main (1).log"),
+                row: None,
+                column: None
+            }
+        );
     }
 
     #[perf]
@@ -2175,6 +2185,15 @@ mod tests {
                 column: None
             }
         );
+
+        assert_eq!(
+            PathWithPosition::parse_str("C:\\Users\\someone\\main (1).log"),
+            PathWithPosition {
+                path: PathBuf::from("C:\\Users\\someone\\main (1).log"),
+                row: None,
+                column: None
+            }
+        );
     }
 
     #[perf]

crates/vim/Cargo.toml 🔗

@@ -54,11 +54,9 @@ workspace.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]
-assets.workspace = true
 command_palette = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 git_ui = { workspace = true, features = ["test-support"] }
-title_bar = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }

crates/vim/src/helix.rs 🔗

@@ -36,6 +36,8 @@ actions!(
         HelixInsert,
         /// Appends at the end of the selection.
         HelixAppend,
+        /// Inserts at the end of the current Helix cursor line.
+        HelixInsertEndOfLine,
         /// Goes to the location of the last modification.
         HelixGotoLastModification,
         /// Select entire line or multiple lines, extending downwards.
@@ -64,6 +66,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::helix_select_lines);
     Vim::action(editor, cx, Vim::helix_insert);
     Vim::action(editor, cx, Vim::helix_append);
+    Vim::action(editor, cx, Vim::helix_insert_end_of_line);
     Vim::action(editor, cx, Vim::helix_yank);
     Vim::action(editor, cx, Vim::helix_goto_last_modification);
     Vim::action(editor, cx, Vim::helix_paste);
@@ -600,6 +603,34 @@ impl Vim {
         });
     }
 
+    /// Helix-specific implementation of `shift-a` that accounts for Helix's
+    /// selection model, where selecting a line with `x` creates a selection
+    /// from column 0 of the current row to column 0 of the next row, so the
+    /// default [`vim::normal::InsertEndOfLine`] would move the cursor to the
+    /// end of the wrong line.
+    fn helix_insert_end_of_line(
+        &mut self,
+        _: &HelixInsertEndOfLine,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.start_recording(cx);
+        self.switch_mode(Mode::Insert, false, window, cx);
+        self.update_editor(cx, |_, editor, cx| {
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.move_with(&mut |map, selection| {
+                    let cursor = if !selection.is_empty() && !selection.reversed {
+                        movement::left(map, selection.head())
+                    } else {
+                        selection.head()
+                    };
+                    selection
+                        .collapse_to(motion::next_line_end(map, cursor, 1), SelectionGoal::None);
+                });
+            });
+        });
+    }
+
     pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
         self.update_editor(cx, |_, editor, cx| {
             editor.transact(window, cx, |editor, window, cx| {
@@ -1447,6 +1478,47 @@ mod test {
             ˇ»line five"},
             Mode::HelixNormal,
         );
+
+        // Test selecting with an empty line below the current line
+        cx.set_state(
+            indoc! {"
+            line one
+            line twoˇ
+
+            line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «line two
+            ˇ»
+            line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «line two
+
+            ˇ»line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «line two
+
+            line four
+            ˇ»line five"},
+            Mode::HelixNormal,
+        );
     }
 
     #[gpui::test]
@@ -1848,4 +1920,51 @@ mod test {
             Mode::HelixSelect,
         );
     }
+
+    #[gpui::test]
+    async fn test_helix_insert_end_of_line(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        // Ensure that, when lines are selected using `x`, pressing `shift-a`
+        // actually puts the cursor at the end of the selected lines and not at
+        // the end of the line below.
+        cx.set_state(
+            indoc! {"
+            line oˇne
+            line two"},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            «line one
+            ˇ»line two"},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("shift-a");
+        cx.assert_state(
+            indoc! {"
+            line oneˇ
+            line two"},
+            Mode::Insert,
+        );
+
+        cx.set_state(
+            indoc! {"
+            line «one
+            lineˇ» two"},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("shift-a");
+        cx.assert_state(
+            indoc! {"
+            line one
+            line twoˇ"},
+            Mode::Insert,
+        );
+    }
 }

crates/vim/src/state.rs 🔗

@@ -73,6 +73,10 @@ impl Mode {
             Self::Normal | Self::Insert | Self::Replace | Self::HelixNormal => false,
         }
     }
+
+    pub fn is_helix(&self) -> bool {
+        matches!(self, Self::HelixNormal | Self::HelixSelect)
+    }
 }
 
 #[derive(Clone, Debug, PartialEq)]
@@ -515,7 +519,7 @@ impl MarksState {
         cx: &mut Context<Self>,
     ) {
         let on_change = cx.subscribe(buffer_handle, move |this, buffer, event, cx| match event {
-            BufferEvent::Edited => {
+            BufferEvent::Edited { .. } => {
                 if let Some(path) = this.path_for_buffer(&buffer, cx) {
                     this.serialize_buffer_marks(path, &buffer, cx);
                 }

crates/vim/src/vim.rs 🔗

@@ -978,6 +978,7 @@ impl Vim {
         editor.set_clip_at_line_ends(false, cx);
         editor.set_collapse_matches(false);
         editor.set_input_enabled(true);
+        editor.set_expects_character_input(true);
         editor.set_autoindent(true);
         editor.selections.set_line_mode(false);
         editor.unregister_addon::<VimAddon>();
@@ -1346,6 +1347,15 @@ impl Vim {
         }
     }
 
+    fn expects_character_input(&self) -> bool {
+        if let Some(operator) = self.operator_stack.last() {
+            if operator.is_waiting(self.mode) {
+                return true;
+            }
+        }
+        self.editor_input_enabled()
+    }
+
     pub fn editor_input_enabled(&self) -> bool {
         match self.mode {
             Mode::Insert => {
@@ -2058,8 +2068,9 @@ impl Vim {
             clip_at_line_ends: self.clip_at_line_ends(),
             collapse_matches: !HelixModeSetting::get_global(cx).0,
             input_enabled: self.editor_input_enabled(),
+            expects_character_input: self.expects_character_input(),
             autoindent: self.should_autoindent(),
-            cursor_offset_on_selection: self.mode.is_visual(),
+            cursor_offset_on_selection: self.mode.is_visual() || self.mode.is_helix(),
             line_mode: matches!(self.mode, Mode::VisualLine),
             hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace),
         }
@@ -2075,6 +2086,7 @@ impl Vim {
         editor.set_clip_at_line_ends(state.clip_at_line_ends, cx);
         editor.set_collapse_matches(state.collapse_matches);
         editor.set_input_enabled(state.input_enabled);
+        editor.set_expects_character_input(state.expects_character_input);
         editor.set_autoindent(state.autoindent);
         editor.set_cursor_offset_on_selection(state.cursor_offset_on_selection);
         editor.selections.set_line_mode(state.line_mode);
@@ -2087,6 +2099,7 @@ struct VimEditorSettingsState {
     clip_at_line_ends: bool,
     collapse_matches: bool,
     input_enabled: bool,
+    expects_character_input: bool,
     autoindent: bool,
     cursor_offset_on_selection: bool,
     line_mode: bool,

crates/watch/Cargo.toml 🔗

@@ -19,5 +19,4 @@ parking_lot.workspace = true
 ctor.workspace = true
 futures.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
-rand.workspace = true
 zlog.workspace = true

crates/web_search_providers/src/cloud.rs 🔗

@@ -5,9 +5,9 @@ use client::{Client, UserStore};
 use cloud_api_types::OrganizationId;
 use cloud_llm_client::{WebSearchBody, WebSearchResponse};
 use futures::AsyncReadExt as _;
-use gpui::{App, AppContext, Context, Entity, Subscription, Task};
+use gpui::{App, AppContext, Context, Entity, Task};
 use http_client::{HttpClient, Method};
-use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener};
+use language_model::{LlmApiToken, NeedsLlmTokenRefresh};
 use web_search::{WebSearchProvider, WebSearchProviderId};
 
 pub struct CloudWebSearchProvider {
@@ -26,34 +26,16 @@ pub struct State {
     client: Arc<Client>,
     user_store: Entity<UserStore>,
     llm_api_token: LlmApiToken,
-    _llm_token_subscription: Subscription,
 }
 
 impl State {
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
-        let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
+        let llm_api_token = LlmApiToken::global(cx);
 
         Self {
             client,
             user_store,
-            llm_api_token: LlmApiToken::default(),
-            _llm_token_subscription: cx.subscribe(
-                &refresh_llm_token_listener,
-                |this, _, _event, cx| {
-                    let client = this.client.clone();
-                    let llm_api_token = this.llm_api_token.clone();
-                    let organization_id = this
-                        .user_store
-                        .read(cx)
-                        .current_organization()
-                        .map(|o| o.id.clone());
-                    cx.spawn(async move |_this, _cx| {
-                        llm_api_token.refresh(&client, organization_id).await?;
-                        anyhow::Ok(())
-                    })
-                    .detach_and_log_err(cx);
-                },
-            ),
+            llm_api_token,
         }
     }
 }
@@ -73,7 +55,7 @@ impl WebSearchProvider for CloudWebSearchProvider {
             .user_store
             .read(cx)
             .current_organization()
-            .map(|o| o.id.clone());
+            .map(|organization| organization.id.clone());
         let body = WebSearchBody { query };
         cx.background_spawn(async move {
             perform_web_search(client, llm_api_token, organization_id, body).await

crates/workspace/Cargo.toml 🔗

@@ -72,7 +72,6 @@ windows.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
-dap = { workspace = true, features = ["test-support"] }
 db = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }

crates/workspace/src/item.rs 🔗

@@ -12,10 +12,11 @@ use client::{Client, proto};
 use futures::{StreamExt, channel::mpsc};
 use gpui::{
     Action, AnyElement, AnyEntity, AnyView, App, AppContext, Context, Entity, EntityId,
-    EventEmitter, FocusHandle, Focusable, Font, HighlightStyle, Pixels, Point, Render,
-    SharedString, Task, WeakEntity, Window,
+    EventEmitter, FocusHandle, Focusable, Font, Pixels, Point, Render, SharedString, Task,
+    WeakEntity, Window,
 };
 use language::Capability;
+pub use language::HighlightedText;
 use project::{Project, ProjectEntryId, ProjectPath};
 pub use settings::{
     ActivateOnClose, ClosePosition, RegisterSetting, Settings, SettingsLocation, ShowCloseButton,
@@ -25,7 +26,6 @@ use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
     cell::RefCell,
-    ops::Range,
     path::Path,
     rc::Rc,
     sync::Arc,
@@ -124,14 +124,6 @@ pub enum ItemEvent {
     Edit,
 }
 
-// TODO: Combine this with existing HighlightedText struct?
-#[derive(Debug)]
-pub struct BreadcrumbText {
-    pub text: String,
-    pub highlights: Option<Vec<(Range<usize>, HighlightStyle)>>,
-    pub font: Option<Font>,
-}
-
 #[derive(Clone, Copy, Default, Debug)]
 pub struct TabContentParams {
     pub detail: Option<usize>,
@@ -329,7 +321,7 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
         ToolbarItemLocation::Hidden
     }
 
-    fn breadcrumbs(&self, _cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, _cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         None
     }
 
@@ -366,6 +358,18 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
         true
     }
 
+    /// Called when the containing pane receives a drop on the item or the item's tab.
+    /// Returns `true` to consume it and suppress the pane's default drop behavior.
+    fn handle_drop(
+        &self,
+        _active_pane: &Pane,
+        _dropped: &dyn Any,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> bool {
+        false
+    }
+
     /// Returns additional actions to add to the tab's context menu.
     /// Each entry is a label and an action to dispatch.
     fn tab_extra_context_menu_actions(
@@ -536,7 +540,7 @@ pub trait ItemHandle: 'static + Send {
     ) -> gpui::Subscription;
     fn to_searchable_item_handle(&self, cx: &App) -> Option<Box<dyn SearchableItemHandle>>;
     fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation;
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>>;
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)>;
     fn breadcrumb_prefix(&self, window: &mut Window, cx: &mut App) -> Option<gpui::AnyElement>;
     fn show_toolbar(&self, cx: &App) -> bool;
     fn pixel_position_of_cursor(&self, cx: &App) -> Option<Point<Pixels>>;
@@ -545,6 +549,13 @@ pub trait ItemHandle: 'static + Send {
     fn preserve_preview(&self, cx: &App) -> bool;
     fn include_in_nav_history(&self) -> bool;
     fn relay_action(&self, action: Box<dyn Action>, window: &mut Window, cx: &mut App);
+    fn handle_drop(
+        &self,
+        active_pane: &Pane,
+        dropped: &dyn Any,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> bool;
     fn tab_extra_context_menu_actions(
         &self,
         window: &mut Window,
@@ -1071,7 +1082,7 @@ impl<T: Item> ItemHandle for Entity<T> {
         self.read(cx).breadcrumb_location(cx)
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.read(cx).breadcrumbs(cx)
     }
 
@@ -1110,6 +1121,20 @@ impl<T: Item> ItemHandle for Entity<T> {
         })
     }
 
+    /// Called when the containing pane receives a drop on the item or the item's tab.
+    /// Returns `true` if the item handled it and the pane should skip its default drop behavior.
+    fn handle_drop(
+        &self,
+        active_pane: &Pane,
+        dropped: &dyn Any,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> bool {
+        self.update(cx, |this, cx| {
+            this.handle_drop(active_pane, dropped, window, cx)
+        })
+    }
+
     fn tab_extra_context_menu_actions(
         &self,
         window: &mut Window,

crates/workspace/src/multi_workspace.rs 🔗

@@ -1,9 +1,8 @@
 use anyhow::Result;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
-    AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
-    ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
-    actions, deferred, px,
+    App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render,
+    Subscription, Task, Tiling, Window, WindowId, actions, px,
 };
 use project::{DisableAiSettings, Project};
 use settings::Settings;
@@ -12,11 +11,12 @@ use std::path::PathBuf;
 use ui::prelude::*;
 use util::ResultExt;
 
-const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
 
 use crate::{
     CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast,
     Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId,
+    persistence::model::MultiWorkspaceId,
 };
 
 actions!(
@@ -35,25 +35,10 @@ actions!(
     ]
 );
 
-pub enum SidebarEvent {
-    Open,
-    Close,
-}
-
-pub trait Sidebar: EventEmitter<SidebarEvent> + Focusable + Render + Sized {
-    fn width(&self, cx: &App) -> Pixels;
-    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
-    fn has_notifications(&self, cx: &App) -> bool;
-}
-
-pub trait SidebarHandle: 'static + Send + Sync {
-    fn width(&self, cx: &App) -> Pixels;
-    fn set_width(&self, width: Option<Pixels>, cx: &mut App);
-    fn focus_handle(&self, cx: &App) -> FocusHandle;
-    fn focus(&self, window: &mut Window, cx: &mut App);
-    fn has_notifications(&self, cx: &App) -> bool;
-    fn to_any(&self) -> AnyView;
-    fn entity_id(&self) -> EntityId;
+pub enum MultiWorkspaceEvent {
+    ActiveWorkspaceChanged,
+    WorkspaceAdded(Entity<Workspace>),
+    WorkspaceRemoved(EntityId),
 }
 
 #[derive(Clone)]
@@ -65,50 +50,23 @@ impl Render for DraggedSidebar {
     }
 }
 
-impl<T: Sidebar> SidebarHandle for Entity<T> {
-    fn width(&self, cx: &App) -> Pixels {
-        self.read(cx).width(cx)
-    }
-
-    fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
-        self.update(cx, |this, cx| this.set_width(width, cx))
-    }
-
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.read(cx).focus_handle(cx)
-    }
-
-    fn focus(&self, window: &mut Window, cx: &mut App) {
-        let handle = self.read(cx).focus_handle(cx);
-        window.focus(&handle, cx);
-    }
-
-    fn has_notifications(&self, cx: &App) -> bool {
-        self.read(cx).has_notifications(cx)
-    }
-
-    fn to_any(&self) -> AnyView {
-        self.clone().into()
-    }
-
-    fn entity_id(&self) -> EntityId {
-        Entity::entity_id(self)
-    }
-}
-
 pub struct MultiWorkspace {
     window_id: WindowId,
     workspaces: Vec<Entity<Workspace>>,
+    database_id: Option<MultiWorkspaceId>,
     active_workspace_index: usize,
-    sidebar: Option<Box<dyn SidebarHandle>>,
-    sidebar_open: bool,
-    _sidebar_subscription: Option<Subscription>,
     pending_removal_tasks: Vec<Task<()>>,
     _serialize_task: Option<Task<()>>,
     _create_task: Option<Task<()>>,
     _subscriptions: Vec<Subscription>,
 }
 
+impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
+
+pub fn multi_workspace_enabled(cx: &App) -> bool {
+    cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
+}
+
 impl MultiWorkspace {
     pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
@@ -123,132 +81,19 @@ impl MultiWorkspace {
             }
         });
         let quit_subscription = cx.on_app_quit(Self::app_will_quit);
-        let settings_subscription =
-            cx.observe_global_in::<settings::SettingsStore>(window, |this, window, cx| {
-                if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open {
-                    this.close_sidebar(window, cx);
-                }
-            });
         Self::subscribe_to_workspace(&workspace, cx);
         Self {
             window_id: window.window_handle().window_id(),
+            database_id: None,
             workspaces: vec![workspace],
             active_workspace_index: 0,
-            sidebar: None,
-            sidebar_open: false,
-            _sidebar_subscription: None,
             pending_removal_tasks: Vec::new(),
             _serialize_task: None,
             _create_task: None,
-            _subscriptions: vec![
-                release_subscription,
-                quit_subscription,
-                settings_subscription,
-            ],
-        }
-    }
-
-    pub fn register_sidebar<T: Sidebar>(
-        &mut self,
-        sidebar: Entity<T>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let subscription =
-            cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
-                SidebarEvent::Open => this.toggle_sidebar(window, cx),
-                SidebarEvent::Close => {
-                    this.close_sidebar(window, cx);
-                }
-            });
-        self.sidebar = Some(Box::new(sidebar));
-        self._sidebar_subscription = Some(subscription);
-    }
-
-    pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
-        self.sidebar.as_deref()
-    }
-
-    pub fn sidebar_open(&self) -> bool {
-        self.sidebar_open && self.sidebar.is_some()
-    }
-
-    pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
-        self.sidebar
-            .as_ref()
-            .map_or(false, |s| s.has_notifications(cx))
-    }
-
-    pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
-        cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
-    }
-
-    pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
-            return;
-        }
-
-        if self.sidebar_open {
-            self.close_sidebar(window, cx);
-        } else {
-            self.open_sidebar(cx);
-            if let Some(sidebar) = &self.sidebar {
-                sidebar.focus(window, cx);
-            }
-        }
-    }
-
-    pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
-            return;
-        }
-
-        if self.sidebar_open {
-            let sidebar_is_focused = self
-                .sidebar
-                .as_ref()
-                .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
-
-            if sidebar_is_focused {
-                let pane = self.workspace().read(cx).active_pane().clone();
-                let pane_focus = pane.read(cx).focus_handle(cx);
-                window.focus(&pane_focus, cx);
-            } else if let Some(sidebar) = &self.sidebar {
-                sidebar.focus(window, cx);
-            }
-        } else {
-            self.open_sidebar(cx);
-            if let Some(sidebar) = &self.sidebar {
-                sidebar.focus(window, cx);
-            }
+            _subscriptions: vec![release_subscription, quit_subscription],
         }
     }
 
-    pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
-        self.sidebar_open = true;
-        for workspace in &self.workspaces {
-            workspace.update(cx, |workspace, cx| {
-                workspace.set_workspace_sidebar_open(true, cx);
-            });
-        }
-        self.serialize(cx);
-        cx.notify();
-    }
-
-    fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.sidebar_open = false;
-        for workspace in &self.workspaces {
-            workspace.update(cx, |workspace, cx| {
-                workspace.set_workspace_sidebar_open(false, cx);
-            });
-        }
-        let pane = self.workspace().read(cx).active_pane().clone();
-        let pane_focus = pane.read(cx).focus_handle(cx);
-        window.focus(&pane_focus, cx);
-        self.serialize(cx);
-        cx.notify();
-    }
-
     pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
         cx.spawn_in(window, async move |this, cx| {
             let workspaces = this.update(cx, |multi_workspace, _cx| {
@@ -284,10 +129,6 @@ impl MultiWorkspace {
         .detach();
     }
 
-    pub fn is_sidebar_open(&self) -> bool {
-        self.sidebar_open
-    }
-
     pub fn workspace(&self) -> &Entity<Workspace> {
         &self.workspaces[self.active_workspace_index]
     }
@@ -301,9 +142,10 @@ impl MultiWorkspace {
     }
 
     pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
+        if !multi_workspace_enabled(cx) {
             self.workspaces[0] = workspace;
             self.active_workspace_index = 0;
+            cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
             cx.notify();
             return;
         }
@@ -321,7 +163,11 @@ impl MultiWorkspace {
         cx: &mut Context<Self>,
     ) -> usize {
         let index = self.add_workspace(workspace, cx);
+        let changed = self.active_workspace_index != index;
         self.active_workspace_index = index;
+        if changed {
+            cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
+        }
         cx.notify();
         index
     }
@@ -332,26 +178,34 @@ impl MultiWorkspace {
         if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
             index
         } else {
-            if self.sidebar_open {
-                workspace.update(cx, |workspace, cx| {
-                    workspace.set_workspace_sidebar_open(true, cx);
-                });
-            }
             Self::subscribe_to_workspace(&workspace, cx);
-            self.workspaces.push(workspace);
+            self.workspaces.push(workspace.clone());
+            cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
             cx.notify();
             self.workspaces.len() - 1
         }
     }
 
+    pub fn database_id(&self) -> Option<MultiWorkspaceId> {
+        self.database_id
+    }
+
+    pub fn set_database_id(&mut self, id: Option<MultiWorkspaceId>) {
+        self.database_id = id;
+    }
+
     pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
         debug_assert!(
             index < self.workspaces.len(),
             "workspace index out of bounds"
         );
+        let changed = self.active_workspace_index != index;
         self.active_workspace_index = index;
         self.serialize(cx);
         self.focus_active_workspace(window, cx);
+        if changed {
+            cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
+        }
         cx.notify();
     }
 
@@ -377,7 +231,6 @@ impl MultiWorkspace {
         let window_id = self.window_id;
         let state = crate::persistence::model::MultiWorkspaceState {
             active_workspace_id: self.workspace().read(cx).database_id(),
-            sidebar_open: self.sidebar_open,
         };
         self._serialize_task = Some(cx.background_spawn(async move {
             crate::persistence::write_multi_workspace_state(window_id, state).await;
@@ -406,7 +259,7 @@ impl MultiWorkspace {
         }
     }
 
-    fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
+    pub fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
         // If a dock panel is zoomed, focus it instead of the center pane.
         // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal
         // which closes the zoomed dock.
@@ -496,7 +349,7 @@ impl MultiWorkspace {
         self.workspace().read(cx).items_of_type::<T>(cx)
     }
 
-    pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
+    pub fn active_workspace_database_id(&self, cx: &App) -> Option<WorkspaceId> {
         self.workspace().read(cx).database_id()
     }
 
@@ -539,7 +392,7 @@ impl MultiWorkspace {
     }
 
     pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if !self.multi_workspace_enabled(cx) {
+        if !multi_workspace_enabled(cx) {
             return;
         }
         let app_state = self.workspace().read(cx).app_state().clone();
@@ -633,6 +486,10 @@ impl MultiWorkspace {
 
         self.serialize(cx);
         self.focus_active_workspace(window, cx);
+        cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(
+            removed_workspace.entity_id(),
+        ));
+        cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
         cx.notify();
     }
 
@@ -641,10 +498,10 @@ impl MultiWorkspace {
         paths: Vec<PathBuf>,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> Task<Result<()>> {
+    ) -> Task<Result<Entity<Workspace>>> {
         let workspace = self.workspace().clone();
 
-        if self.multi_workspace_enabled(cx) {
+        if multi_workspace_enabled(cx) {
             workspace.update(cx, |workspace, cx| {
                 workspace.open_workspace_for_paths(true, paths, window, cx)
             })
@@ -662,7 +519,7 @@ impl MultiWorkspace {
                         })?
                         .await
                 } else {
-                    Ok(())
+                    Ok(workspace)
                 }
             })
         }
@@ -671,57 +528,6 @@ impl MultiWorkspace {
 
 impl Render for MultiWorkspace {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let multi_workspace_enabled = self.multi_workspace_enabled(cx);
-
-        let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open {
-            self.sidebar.as_ref().map(|sidebar_handle| {
-                let weak = cx.weak_entity();
-
-                let sidebar_width = sidebar_handle.width(cx);
-                let resize_handle = deferred(
-                    div()
-                        .id("sidebar-resize-handle")
-                        .absolute()
-                        .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
-                        .top(px(0.))
-                        .h_full()
-                        .w(SIDEBAR_RESIZE_HANDLE_SIZE)
-                        .cursor_col_resize()
-                        .on_drag(DraggedSidebar, |dragged, _, _, cx| {
-                            cx.stop_propagation();
-                            cx.new(|_| dragged.clone())
-                        })
-                        .on_mouse_down(MouseButton::Left, |_, _, cx| {
-                            cx.stop_propagation();
-                        })
-                        .on_mouse_up(MouseButton::Left, move |event, _, cx| {
-                            if event.click_count == 2 {
-                                weak.update(cx, |this, cx| {
-                                    if let Some(sidebar) = this.sidebar.as_mut() {
-                                        sidebar.set_width(None, cx);
-                                    }
-                                })
-                                .ok();
-                                cx.stop_propagation();
-                            }
-                        })
-                        .occlude(),
-                );
-
-                div()
-                    .id("sidebar-container")
-                    .relative()
-                    .h_full()
-                    .w(sidebar_width)
-                    .flex_shrink_0()
-                    .child(sidebar_handle.to_any())
-                    .child(resize_handle)
-                    .into_any_element()
-            })
-        } else {
-            None
-        };
-
         let ui_font = theme::setup_ui_font(window, cx);
         let text_color = cx.theme().colors().text;
 
@@ -751,32 +557,6 @@ impl Render for MultiWorkspace {
                         this.activate_previous_workspace(window, cx);
                     },
                 ))
-                .when(self.multi_workspace_enabled(cx), |this| {
-                    this.on_action(cx.listener(
-                        |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
-                            this.toggle_sidebar(window, cx);
-                        },
-                    ))
-                    .on_action(cx.listener(
-                        |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
-                            this.focus_sidebar(window, cx);
-                        },
-                    ))
-                })
-                .when(
-                    self.sidebar_open() && self.multi_workspace_enabled(cx),
-                    |this| {
-                        this.on_drag_move(cx.listener(
-                            |this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
-                                if let Some(sidebar) = &this.sidebar {
-                                    let new_width = e.event.position.x;
-                                    sidebar.set_width(Some(new_width), cx);
-                                }
-                            },
-                        ))
-                        .children(sidebar)
-                    },
-                )
                 .child(
                     div()
                         .flex()
@@ -789,98 +569,9 @@ impl Render for MultiWorkspace {
             window,
             cx,
             Tiling {
-                left: multi_workspace_enabled && self.sidebar_open,
+                left: false,
                 ..Tiling::default()
             },
         )
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use fs::FakeFs;
-    use gpui::TestAppContext;
-    use settings::SettingsStore;
-
-    fn init_test(cx: &mut TestAppContext) {
-        cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-            theme::init(theme::LoadThemes::JustBase, cx);
-            DisableAiSettings::register(cx);
-            cx.update_flags(false, vec!["agent-v2".into()]);
-        });
-    }
-
-    #[gpui::test]
-    async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
-        init_test(cx);
-        let fs = FakeFs::new(cx.executor());
-        let project = Project::test(fs, [], cx).await;
-
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            assert!(mw.multi_workspace_enabled(cx));
-        });
-
-        multi_workspace.update_in(cx, |mw, _window, cx| {
-            mw.open_sidebar(cx);
-            assert!(mw.is_sidebar_open());
-        });
-
-        cx.update(|_window, cx| {
-            DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
-        });
-        cx.run_until_parked();
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            assert!(
-                !mw.is_sidebar_open(),
-                "Sidebar should be closed when disable_ai is true"
-            );
-            assert!(
-                !mw.multi_workspace_enabled(cx),
-                "Multi-workspace should be disabled when disable_ai is true"
-            );
-        });
-
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.toggle_sidebar(window, cx);
-        });
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert!(
-                !mw.is_sidebar_open(),
-                "Sidebar should remain closed when toggled with disable_ai true"
-            );
-        });
-
-        cx.update(|_window, cx| {
-            DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
-        });
-        cx.run_until_parked();
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            assert!(
-                mw.multi_workspace_enabled(cx),
-                "Multi-workspace should be enabled after re-enabling AI"
-            );
-            assert!(
-                !mw.is_sidebar_open(),
-                "Sidebar should still be closed after re-enabling AI (not auto-opened)"
-            );
-        });
-
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.toggle_sidebar(window, cx);
-        });
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert!(
-                mw.is_sidebar_open(),
-                "Sidebar should open when toggled after re-enabling AI"
-            );
-        });
-    }
-}

crates/workspace/src/notifications.rs 🔗

@@ -657,15 +657,17 @@ impl RenderOnce for NotificationFrame {
                                 IconButton::new(close_id, close_icon)
                                     .tooltip(move |_window, cx| {
                                         if suppress {
-                                            Tooltip::for_action(
-                                                "Suppress.\nClose with click.",
-                                                &SuppressNotification,
+                                            Tooltip::with_meta(
+                                                "Suppress",
+                                                Some(&SuppressNotification),
+                                                "Click to Close",
                                                 cx,
                                             )
                                         } else if show_suppress_button {
-                                            Tooltip::for_action(
-                                                "Close.\nSuppress with shift-click.",
-                                                &menu::Cancel,
+                                            Tooltip::with_meta(
+                                                "Close",
+                                                Some(&menu::Cancel),
+                                                "Shift-click to Suppress",
                                                 cx,
                                             )
                                         } else {
@@ -915,11 +917,11 @@ pub mod simple_message_notification {
                                 }));
 
                             if let Some(icon) = self.primary_icon {
-                                button = button
-                                    .icon(icon)
-                                    .icon_color(self.primary_icon_color.unwrap_or(Color::Muted))
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::Small);
+                                button = button.start_icon(
+                                    Icon::new(icon)
+                                        .size(IconSize::Small)
+                                        .color(self.primary_icon_color.unwrap_or(Color::Muted)),
+                                );
                             }
 
                             button
@@ -935,11 +937,11 @@ pub mod simple_message_notification {
                                 }));
 
                             if let Some(icon) = self.secondary_icon {
-                                button = button
-                                    .icon(icon)
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(self.secondary_icon_color.unwrap_or(Color::Muted));
+                                button = button.start_icon(
+                                    Icon::new(icon)
+                                        .size(IconSize::Small)
+                                        .color(self.secondary_icon_color.unwrap_or(Color::Muted)),
+                                );
                             }
 
                             button
@@ -953,9 +955,11 @@ pub mod simple_message_notification {
                                         let url = url.clone();
                                         Button::new(message.clone(), message.clone())
                                             .label_size(LabelSize::Small)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::Indicator)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::Indicator)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(cx.listener(move |_, _, _, cx| {
                                                 cx.open_url(&url);
                                             }))

crates/workspace/src/pane.rs 🔗

@@ -34,7 +34,6 @@ use std::{
     any::Any,
     cmp, fmt, mem,
     num::NonZeroUsize,
-    ops::ControlFlow,
     path::PathBuf,
     rc::Rc,
     sync::{
@@ -382,9 +381,6 @@ pub struct Pane {
     project: WeakEntity<Project>,
     pub drag_split_direction: Option<SplitDirection>,
     can_drop_predicate: Option<Arc<dyn Fn(&dyn Any, &mut Window, &mut App) -> bool>>,
-    custom_drop_handle: Option<
-        Arc<dyn Fn(&mut Pane, &dyn Any, &mut Window, &mut Context<Pane>) -> ControlFlow<(), ()>>,
-    >,
     can_split_predicate:
         Option<Arc<dyn Fn(&mut Self, &dyn Any, &mut Window, &mut Context<Self>) -> bool>>,
     can_toggle_zoom: bool,
@@ -567,7 +563,6 @@ impl Pane {
             workspace,
             project: project.downgrade(),
             can_drop_predicate,
-            custom_drop_handle: None,
             can_split_predicate: None,
             can_toggle_zoom: true,
             should_display_tab_bar: Rc::new(|_, cx| TabBarSettings::get_global(cx).show),
@@ -846,15 +841,6 @@ impl Pane {
         cx.notify();
     }
 
-    pub fn set_custom_drop_handle<F>(&mut self, cx: &mut Context<Self>, handle: F)
-    where
-        F: 'static
-            + Fn(&mut Pane, &dyn Any, &mut Window, &mut Context<Pane>) -> ControlFlow<(), ()>,
-    {
-        self.custom_drop_handle = Some(Arc::new(handle));
-        cx.notify();
-    }
-
     pub fn nav_history_for_item<T: Item>(&self, item: &Entity<T>) -> ItemNavHistory {
         ItemNavHistory {
             history: self.nav_history.clone(),
@@ -2901,7 +2887,7 @@ impl Pane {
             .on_drop(
                 cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| {
                     this.drag_split_direction = None;
-                    this.handle_tab_drop(dragged_tab, ix, window, cx)
+                    this.handle_tab_drop(dragged_tab, ix, false, window, cx)
                 }),
             )
             .on_drop(
@@ -3550,7 +3536,7 @@ impl Pane {
             .on_drop(
                 cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| {
                     this.drag_split_direction = None;
-                    this.handle_tab_drop(dragged_tab, this.items.len(), window, cx)
+                    this.handle_tab_drop(dragged_tab, this.items.len(), false, window, cx)
                 }),
             )
             .on_drop(
@@ -3691,14 +3677,18 @@ impl Pane {
         &mut self,
         dragged_tab: &DraggedTab,
         ix: usize,
+        is_pane_target: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
-            && let ControlFlow::Break(()) = custom_drop_handle(self, dragged_tab, window, cx)
+        if is_pane_target
+            && ix == self.active_item_index
+            && let Some(active_item) = self.active_item()
+            && active_item.handle_drop(self, dragged_tab, window, cx)
         {
             return;
         }
+
         let mut to_pane = cx.entity();
         let split_direction = self.drag_split_direction;
         let item_id = dragged_tab.item.item_id();
@@ -3791,7 +3781,7 @@ impl Pane {
         let item_id = dragged_tab.item.item_id();
         let pinned_count = self.pinned_tab_count;
 
-        self.handle_tab_drop(dragged_tab, pinned_count, window, cx);
+        self.handle_tab_drop(dragged_tab, pinned_count, false, window, cx);
 
         let to_pane = cx.entity();
 
@@ -3843,11 +3833,12 @@ impl Pane {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
-            && let ControlFlow::Break(()) = custom_drop_handle(self, dragged_selection, window, cx)
+        if let Some(active_item) = self.active_item()
+            && active_item.handle_drop(self, dragged_selection, window, cx)
         {
             return;
         }
+
         self.handle_project_entry_drop(
             &dragged_selection.active_selection.entry_id,
             dragged_onto,
@@ -3863,11 +3854,12 @@ impl Pane {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
-            && let ControlFlow::Break(()) = custom_drop_handle(self, project_entry_id, window, cx)
+        if let Some(active_item) = self.active_item()
+            && active_item.handle_drop(self, project_entry_id, window, cx)
         {
             return;
         }
+
         let mut to_pane = cx.entity();
         let split_direction = self.drag_split_direction;
         let project_entry_id = *project_entry_id;
@@ -3939,11 +3931,12 @@ impl Pane {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
-            && let ControlFlow::Break(()) = custom_drop_handle(self, paths, window, cx)
+        if let Some(active_item) = self.active_item()
+            && active_item.handle_drop(self, paths, window, cx)
         {
             return;
         }
+
         let mut to_pane = cx.entity();
         let mut split_direction = self.drag_split_direction;
         let paths = paths.paths().to_vec();
@@ -4424,6 +4417,7 @@ impl Render for Pane {
                                 this.handle_tab_drop(
                                     dragged_tab,
                                     this.active_item_index(),
+                                    true,
                                     window,
                                     cx,
                                 )
@@ -4826,7 +4820,7 @@ impl Render for DraggedTab {
 
 #[cfg(test)]
 mod tests {
-    use std::{iter::zip, num::NonZero};
+    use std::{cell::Cell, iter::zip, num::NonZero};
 
     use super::*;
     use crate::{
@@ -4839,6 +4833,65 @@ mod tests {
     use theme::LoadThemes;
     use util::TryFutureExt;
 
+    // drop_call_count is a Cell here because `handle_drop` takes &self, not &mut self.
+    struct CustomDropHandlingItem {
+        focus_handle: gpui::FocusHandle,
+        drop_call_count: Cell<usize>,
+    }
+
+    impl CustomDropHandlingItem {
+        fn new(cx: &mut Context<Self>) -> Self {
+            Self {
+                focus_handle: cx.focus_handle(),
+                drop_call_count: Cell::new(0),
+            }
+        }
+
+        fn drop_call_count(&self) -> usize {
+            self.drop_call_count.get()
+        }
+    }
+
+    impl EventEmitter<()> for CustomDropHandlingItem {}
+
+    impl Focusable for CustomDropHandlingItem {
+        fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+            self.focus_handle.clone()
+        }
+    }
+
+    impl Render for CustomDropHandlingItem {
+        fn render(
+            &mut self,
+            _window: &mut Window,
+            _cx: &mut Context<Self>,
+        ) -> impl gpui::IntoElement {
+            gpui::Empty
+        }
+    }
+
+    impl Item for CustomDropHandlingItem {
+        type Event = ();
+
+        fn tab_content_text(&self, _detail: usize, _cx: &App) -> gpui::SharedString {
+            "custom_drop_handling_item".into()
+        }
+
+        fn handle_drop(
+            &self,
+            _active_pane: &Pane,
+            dropped: &dyn std::any::Any,
+            _window: &mut Window,
+            _cx: &mut App,
+        ) -> bool {
+            let is_dragged_tab = dropped.downcast_ref::<DraggedTab>().is_some();
+            if is_dragged_tab {
+                self.drop_call_count.set(self.drop_call_count.get() + 1);
+            }
+            is_dragged_tab
+        }
+    }
+
     #[gpui::test]
     async fn test_add_item_capped_to_max_tabs(cx: &mut TestAppContext) {
         init_test(cx);
@@ -5664,6 +5717,83 @@ mod tests {
         assert_item_labels(&pane, ["C", "A", "B*"], cx);
     }
 
+    #[gpui::test]
+    async fn test_handle_tab_drop_respects_is_pane_target(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, None, cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let source_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
+
+        let item_a = add_labeled_item(&source_pane, "A", false, cx);
+        let item_b = add_labeled_item(&source_pane, "B", false, cx);
+
+        let target_pane = workspace.update_in(cx, |workspace, window, cx| {
+            workspace.split_pane(source_pane.clone(), SplitDirection::Right, window, cx)
+        });
+
+        let custom_item = target_pane.update_in(cx, |pane, window, cx| {
+            let custom_item = Box::new(cx.new(CustomDropHandlingItem::new));
+            pane.add_item(custom_item.clone(), true, true, None, window, cx);
+            custom_item
+        });
+
+        let moved_item_id = item_a.item_id();
+        let other_item_id = item_b.item_id();
+        let custom_item_id = custom_item.item_id();
+
+        let pane_item_ids = |pane: &Entity<Pane>, cx: &mut VisualTestContext| {
+            pane.read_with(cx, |pane, _| {
+                pane.items().map(|item| item.item_id()).collect::<Vec<_>>()
+            })
+        };
+
+        let source_before_item_ids = pane_item_ids(&source_pane, cx);
+        assert_eq!(source_before_item_ids, vec![moved_item_id, other_item_id]);
+
+        let target_before_item_ids = pane_item_ids(&target_pane, cx);
+        assert_eq!(target_before_item_ids, vec![custom_item_id]);
+
+        let dragged_tab = DraggedTab {
+            pane: source_pane.clone(),
+            item: item_a.boxed_clone(),
+            ix: 0,
+            detail: 0,
+            is_active: true,
+        };
+
+        // Dropping item_a onto the target pane itself means the
+        // custom item handles the drop and no tab move should occur
+        target_pane.update_in(cx, |pane, window, cx| {
+            pane.handle_tab_drop(&dragged_tab, pane.active_item_index(), true, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            custom_item.read_with(cx, |item, _| item.drop_call_count()),
+            1
+        );
+        assert_eq!(pane_item_ids(&source_pane, cx), source_before_item_ids);
+        assert_eq!(pane_item_ids(&target_pane, cx), target_before_item_ids);
+
+        // Dropping item_a onto the tab target means the custom handler
+        // should be skipped and the pane's default tab drop behavior should run.
+        target_pane.update_in(cx, |pane, window, cx| {
+            pane.handle_tab_drop(&dragged_tab, pane.active_item_index(), false, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            custom_item.read_with(cx, |item, _| item.drop_call_count()),
+            1
+        );
+        assert_eq!(pane_item_ids(&source_pane, cx), vec![other_item_id]);
+
+        let target_item_ids = pane_item_ids(&target_pane, cx);
+        assert_eq!(target_item_ids, vec![moved_item_id, custom_item_id]);
+    }
+
     #[gpui::test]
     async fn test_drag_unpinned_tab_to_split_creates_pane_with_unpinned_tab(
         cx: &mut TestAppContext,
@@ -5699,7 +5829,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, true, window, cx);
         });
 
         // A should be moved to new pane. B should remain pinned, A should not be pinned
@@ -5748,7 +5878,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, true, window, cx);
         });
 
         // A should be moved to new pane. Both A and B should still be pinned
@@ -5798,7 +5928,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, false, window, cx);
         });
 
         // A should stay pinned
@@ -5846,7 +5976,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 1, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 1, false, window, cx);
         });
 
         // A should become pinned
@@ -5890,7 +6020,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, false, window, cx);
         });
 
         // A should stay pinned
@@ -5952,7 +6082,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, false, window, cx);
         });
 
         // E (unpinned) should be closed, leaving 3 pinned items
@@ -5987,7 +6117,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 1, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 1, false, window, cx);
         });
 
         // A should still be pinned and active
@@ -6027,7 +6157,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 2, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 2, false, window, cx);
         });
 
         // A stays pinned
@@ -6064,7 +6194,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 1, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 1, false, window, cx);
         });
 
         // Neither are pinned
@@ -6101,7 +6231,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 2, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 2, false, window, cx);
         });
 
         // A becomes unpinned
@@ -6138,7 +6268,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, false, window, cx);
         });
 
         // A becomes unpinned
@@ -6174,7 +6304,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 1, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 1, false, window, cx);
         });
 
         // A stays pinned, B and C remain unpinned
@@ -6215,7 +6345,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, false, window, cx);
         });
 
         // A should become pinned since it was dropped in the pinned region
@@ -6257,7 +6387,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 1, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 1, true, window, cx);
         });
 
         // A should remain unpinned since it was dropped outside the pinned region
@@ -6304,7 +6434,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 1, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 1, false, window, cx);
         });
 
         // A should be after B and all are pinned
@@ -6319,7 +6449,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 2, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 2, false, window, cx);
         });
 
         // A should be after C and all are pinned
@@ -6334,7 +6464,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 1, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 1, false, window, cx);
         });
 
         // A should be before C and all are pinned
@@ -6349,7 +6479,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, false, window, cx);
         });
 
         // A should be before B and all are pinned
@@ -6381,7 +6511,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 2, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 2, false, window, cx);
         });
 
         // A should be at the end
@@ -6413,7 +6543,7 @@ mod tests {
                 detail: 0,
                 is_active: true,
             };
-            pane.handle_tab_drop(&dragged_tab, 0, window, cx);
+            pane.handle_tab_drop(&dragged_tab, 0, false, window, cx);
         });
 
         // C should be at the beginning

crates/workspace/src/persistence.rs 🔗

@@ -341,6 +341,7 @@ pub fn read_serialized_multi_workspaces(
                 .map(read_multi_workspace_state)
                 .unwrap_or_default();
             model::SerializedMultiWorkspace {
+                id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())),
                 workspaces: group,
                 state,
             }
@@ -1783,11 +1784,17 @@ impl WorkspaceDb {
         }
     }
 
-    async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool {
+    async fn all_paths_exist_with_a_directory(
+        paths: &[PathBuf],
+        fs: &dyn Fs,
+        timestamp: Option<DateTime<Utc>>,
+    ) -> bool {
         let mut any_dir = false;
         for path in paths {
             match fs.metadata(path).await.ok().flatten() {
-                None => return false,
+                None => {
+                    return timestamp.is_some_and(|t| Utc::now() - t < chrono::Duration::days(7));
+                }
                 Some(meta) => {
                     if meta.is_dir {
                         any_dir = true;
@@ -1843,7 +1850,9 @@ impl WorkspaceDb {
             // If a local workspace points to WSL, this check will cause us to wait for the
             // WSL VM and file server to boot up. This can block for many seconds.
             // Supported scenarios use remote workspaces.
-            if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+            if !has_wsl_path
+                && Self::all_paths_exist_with_a_directory(paths.paths(), fs, Some(timestamp)).await
+            {
                 result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
             } else {
                 delete_tasks.push(self.delete_workspace_by_id(id));
@@ -1903,7 +1912,7 @@ impl WorkspaceDb {
                     window_id,
                 });
             } else {
-                if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+                if Self::all_paths_exist_with_a_directory(paths.paths(), fs, None).await {
                     workspaces.push(SessionWorkspace {
                         workspace_id,
                         location: SerializedWorkspaceLocation::Local,
@@ -3877,7 +3886,6 @@ mod tests {
             window_10,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(2)),
-                sidebar_open: true,
             },
         )
         .await;
@@ -3886,7 +3894,6 @@ mod tests {
             window_20,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(3)),
-                sidebar_open: false,
             },
         )
         .await;
@@ -3924,23 +3931,20 @@ mod tests {
         // Should produce 3 groups: window 10, window 20, and the orphan.
         assert_eq!(results.len(), 3);
 
-        // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open.
+        // Window 10 group: 2 workspaces, active_workspace_id = 2.
         let group_10 = &results[0];
         assert_eq!(group_10.workspaces.len(), 2);
         assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
-        assert_eq!(group_10.state.sidebar_open, true);
 
-        // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed.
+        // Window 20 group: 1 workspace, active_workspace_id = 3.
         let group_20 = &results[1];
         assert_eq!(group_20.workspaces.len(), 1);
         assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
-        assert_eq!(group_20.state.sidebar_open, false);
 
         // Orphan group: no window_id, so state is default.
         let group_none = &results[2];
         assert_eq!(group_none.workspaces.len(), 1);
         assert_eq!(group_none.state.active_workspace_id, None);
-        assert_eq!(group_none.state.sidebar_open, false);
     }
 
     #[gpui::test]

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

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

crates/workspace/src/status_bar.rs 🔗

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

crates/workspace/src/welcome.rs 🔗

@@ -10,8 +10,10 @@ use gpui::{
     ParentElement, Render, Styled, Task, Window, actions,
 };
 use menu::{SelectNext, SelectPrevious};
+use project::DisableAiSettings;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use settings::Settings;
 use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
 use util::ResultExt;
 use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette};
@@ -121,21 +123,43 @@ impl RenderOnce for SectionButton {
     }
 }
 
+enum SectionVisibility {
+    Always,
+    Conditional(fn(&App) -> bool),
+}
+
+impl SectionVisibility {
+    fn is_visible(&self, cx: &App) -> bool {
+        match self {
+            SectionVisibility::Always => true,
+            SectionVisibility::Conditional(f) => f(cx),
+        }
+    }
+}
+
 struct SectionEntry {
     icon: IconName,
     title: &'static str,
     action: &'static dyn Action,
+    visibility_guard: SectionVisibility,
 }
 
 impl SectionEntry {
-    fn render(&self, button_index: usize, focus: &FocusHandle, _cx: &App) -> impl IntoElement {
-        SectionButton::new(
-            self.title,
-            self.icon,
-            self.action,
-            button_index,
-            focus.clone(),
-        )
+    fn render(
+        &self,
+        button_index: usize,
+        focus: &FocusHandle,
+        cx: &App,
+    ) -> Option<impl IntoElement> {
+        self.visibility_guard.is_visible(cx).then(|| {
+            SectionButton::new(
+                self.title,
+                self.icon,
+                self.action,
+                button_index,
+                focus.clone(),
+            )
+        })
     }
 }
 
@@ -147,21 +171,25 @@ const CONTENT: (Section<4>, Section<3>) = (
                 icon: IconName::Plus,
                 title: "New File",
                 action: &NewFile,
+                visibility_guard: SectionVisibility::Always,
             },
             SectionEntry {
                 icon: IconName::FolderOpen,
                 title: "Open Project",
                 action: &Open::DEFAULT,
+                visibility_guard: SectionVisibility::Always,
             },
             SectionEntry {
                 icon: IconName::CloudDownload,
                 title: "Clone Repository",
                 action: &GitClone,
+                visibility_guard: SectionVisibility::Always,
             },
             SectionEntry {
                 icon: IconName::ListCollapse,
                 title: "Open Command Palette",
                 action: &command_palette::Toggle,
+                visibility_guard: SectionVisibility::Always,
             },
         ],
     },
@@ -172,11 +200,15 @@ const CONTENT: (Section<4>, Section<3>) = (
                 icon: IconName::Settings,
                 title: "Open Settings",
                 action: &OpenSettings,
+                visibility_guard: SectionVisibility::Always,
             },
             SectionEntry {
                 icon: IconName::ZedAssistant,
                 title: "View AI Settings",
                 action: &agent::OpenSettings,
+                visibility_guard: SectionVisibility::Conditional(|cx| {
+                    !DisableAiSettings::get_global(cx).disable_ai
+                }),
             },
             SectionEntry {
                 icon: IconName::Blocks,
@@ -185,6 +217,7 @@ const CONTENT: (Section<4>, Section<3>) = (
                     category_filter: None,
                     id: None,
                 },
+                visibility_guard: SectionVisibility::Always,
             },
         ],
     },
@@ -204,7 +237,7 @@ impl<const COLS: usize> Section<COLS> {
                 self.entries
                     .iter()
                     .enumerate()
-                    .map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
+                    .filter_map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
             )
     }
 }

crates/workspace/src/workspace.rs 🔗

@@ -27,9 +27,9 @@ mod workspace_settings;
 pub use crate::notifications::NotificationFrame;
 pub use dock::Panel;
 pub use multi_workspace::{
-    DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow,
-    NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle,
-    ToggleWorkspaceSidebar,
+    DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
+    NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow,
+    SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled,
 };
 pub use path_list::{PathList, SerializedPathList};
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
     model::{
-        DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
-        SessionWorkspace,
+        DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace,
+        SerializedWorkspaceLocation, SessionWorkspace,
     },
     read_serialized_multi_workspaces,
 };
@@ -146,7 +146,7 @@ pub use workspace_settings::{
     AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings,
     WorkspaceSettings,
 };
-use zed_actions::{Spawn, feedback::FileBugReport};
+use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode};
 
 use crate::{item::ItemBufferKind, notifications::NotificationId};
 use crate::{
@@ -659,7 +659,7 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
     } else {
         let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx);
         cx.spawn(async move |cx| {
-            let (window, _) = task.await?;
+            let OpenResult { window, .. } = task.await?;
             window.update(cx, |multi_workspace, window, cx| {
                 window.activate_window();
                 let workspace = multi_workspace.workspace().clone();
@@ -1230,6 +1230,7 @@ pub enum Event {
     ZoomChanged,
     ModalOpened,
     Activate,
+    PanelAdded(AnyView),
 }
 
 #[derive(Debug, Clone)]
@@ -1751,12 +1752,7 @@ impl Workspace {
         init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
         activate: bool,
         cx: &mut App,
-    ) -> Task<
-        anyhow::Result<(
-            WindowHandle<MultiWorkspace>,
-            Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
-        )>,
-    > {
+    ) -> Task<anyhow::Result<OpenResult>> {
         let project_handle = Project::local(
             app_state.client.clone(),
             app_state.node_runtime.clone(),
@@ -1996,7 +1992,11 @@ impl Workspace {
                     });
                 })
                 .log_err();
-            Ok((window, opened_items))
+            Ok(OpenResult {
+                window,
+                workspace,
+                opened_items,
+            })
         })
     }
 
@@ -2129,10 +2129,13 @@ impl Workspace {
 
         let dock_position = panel.position(window, cx);
         let dock = self.dock_at_position(dock_position);
+        let any_panel = panel.to_any();
 
         dock.update(cx, |dock, cx| {
             dock.add_panel(panel, self.weak_self.clone(), window, cx)
         });
+
+        cx.emit(Event::PanelAdded(any_panel));
     }
 
     pub fn remove_panel<T: Panel>(
@@ -2150,12 +2153,6 @@ impl Workspace {
         &self.status_bar
     }
 
-    pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) {
-        self.status_bar.update(cx, |status_bar, cx| {
-            status_bar.set_workspace_sidebar_open(open, cx);
-        });
-    }
-
     pub fn status_bar_visible(&self, cx: &App) -> bool {
         StatusBarSettings::get_global(cx).show
     }
@@ -2687,7 +2684,10 @@ impl Workspace {
                 cx,
             );
             cx.spawn_in(window, async move |_vh, cx| {
-                let (multi_workspace_window, _) = task.await?;
+                let OpenResult {
+                    window: multi_workspace_window,
+                    ..
+                } = task.await?;
                 multi_workspace_window.update(cx, |multi_workspace, window, cx| {
                     let workspace = multi_workspace.workspace().clone();
                     workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
@@ -2725,7 +2725,10 @@ impl Workspace {
                 cx,
             );
             cx.spawn_in(window, async move |_vh, cx| {
-                let (multi_workspace_window, _) = task.await?;
+                let OpenResult {
+                    window: multi_workspace_window,
+                    ..
+                } = task.await?;
                 multi_workspace_window.update(cx, |multi_workspace, window, cx| {
                     let workspace = multi_workspace.workspace().clone();
                     workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
@@ -3104,7 +3107,7 @@ impl Workspace {
         paths: Vec<PathBuf>,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> Task<Result<()>> {
+    ) -> Task<Result<Entity<Workspace>>> {
         let window_handle = window.window_handle().downcast::<MultiWorkspace>();
         let is_remote = self.project.read(cx).is_via_collab();
         let has_worktree = self.project.read(cx).worktrees(cx).next().is_some();
@@ -3120,19 +3123,20 @@ impl Workspace {
         let app_state = self.app_state.clone();
 
         cx.spawn(async move |_, cx| {
-            cx.update(|cx| {
-                open_paths(
-                    &paths,
-                    app_state,
-                    OpenOptions {
-                        replace_window: window_to_replace,
-                        ..Default::default()
-                    },
-                    cx,
-                )
-            })
-            .await?;
-            Ok(())
+            let OpenResult { workspace, .. } = cx
+                .update(|cx| {
+                    open_paths(
+                        &paths,
+                        app_state,
+                        OpenOptions {
+                            replace_window: window_to_replace,
+                            ..Default::default()
+                        },
+                        cx,
+                    )
+                })
+                .await?;
+            Ok(workspace)
         })
     }
 
@@ -6501,6 +6505,7 @@ impl Workspace {
             .on_action(cx.listener(Self::move_item_to_pane_at_index))
             .on_action(cx.listener(Self::move_focused_panel_to_next_position))
             .on_action(cx.listener(Self::toggle_edit_predictions_all_files))
+            .on_action(cx.listener(Self::toggle_theme_mode))
             .on_action(cx.listener(|workspace, _: &Unfollow, window, cx| {
                 let pane = workspace.active_pane().clone();
                 workspace.unfollow_in_pane(&pane, window, cx);
@@ -7081,7 +7086,17 @@ impl Workspace {
     }
 
     fn resize_left_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) {
-        let size = new_size.min(self.bounds.right() - RESIZE_HANDLE_SIZE);
+        let workspace_width = self.bounds.size.width;
+        let mut size = new_size.min(workspace_width - RESIZE_HANDLE_SIZE);
+
+        self.right_dock.read_with(cx, |right_dock, cx| {
+            let right_dock_size = right_dock
+                .active_panel_size(window, cx)
+                .unwrap_or(Pixels::ZERO);
+            if right_dock_size + size > workspace_width {
+                size = workspace_width - right_dock_size
+            }
+        });
 
         self.left_dock.update(cx, |left_dock, cx| {
             if WorkspaceSettings::get_global(cx)
@@ -7096,13 +7111,14 @@ impl Workspace {
     }
 
     fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) {
-        let mut size = new_size.max(self.bounds.left() - RESIZE_HANDLE_SIZE);
+        let workspace_width = self.bounds.size.width;
+        let mut size = new_size.min(workspace_width - RESIZE_HANDLE_SIZE);
         self.left_dock.read_with(cx, |left_dock, cx| {
             let left_dock_size = left_dock
                 .active_panel_size(window, cx)
                 .unwrap_or(Pixels::ZERO);
-            if left_dock_size + size > self.bounds.right() {
-                size = self.bounds.right() - left_dock_size
+            if left_dock_size + size > workspace_width {
+                size = workspace_width - left_dock_size
             }
         });
         self.right_dock.update(cx, |right_dock, cx| {
@@ -7144,6 +7160,23 @@ impl Workspace {
         });
     }
 
+    fn toggle_theme_mode(&mut self, _: &ToggleMode, _window: &mut Window, cx: &mut Context<Self>) {
+        let current_mode = ThemeSettings::get_global(cx).theme.mode();
+        let next_mode = match current_mode {
+            Some(theme::ThemeAppearanceMode::Light) => theme::ThemeAppearanceMode::Dark,
+            Some(theme::ThemeAppearanceMode::Dark) => theme::ThemeAppearanceMode::Light,
+            Some(theme::ThemeAppearanceMode::System) | None => match cx.theme().appearance() {
+                theme::Appearance::Light => theme::ThemeAppearanceMode::Dark,
+                theme::Appearance::Dark => theme::ThemeAppearanceMode::Light,
+            },
+        };
+
+        let fs = self.project().read(cx).fs().clone();
+        settings::update_settings_file(fs, cx, move |settings, _cx| {
+            theme::set_mode(settings, next_mode);
+        });
+    }
+
     pub fn show_worktree_trust_security_modal(
         &mut self,
         toggle: bool,
@@ -7663,6 +7696,7 @@ impl Render for Workspace {
                                             {
                                                 workspace.previous_dock_drag_coordinates =
                                                     Some(e.event.position);
+
                                                 match e.drag(cx).0 {
                                                     DockPosition::Left => {
                                                         workspace.resize_left_dock(
@@ -8168,7 +8202,11 @@ pub async fn restore_multiworkspace(
     app_state: Arc<AppState>,
     cx: &mut AsyncApp,
 ) -> anyhow::Result<MultiWorkspaceRestoreResult> {
-    let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
+    let SerializedMultiWorkspace {
+        workspaces,
+        state,
+        id: window_id,
+    } = multi_workspace;
     let mut group_iter = workspaces.into_iter();
     let first = group_iter
         .next()
@@ -8178,7 +8216,7 @@ pub async fn restore_multiworkspace(
         cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx))
             .await?
     } else {
-        let (window, _items) = cx
+        let OpenResult { window, .. } = cx
             .update(|cx| {
                 Workspace::new_local(
                     first.paths.paths().to_vec(),
@@ -8232,6 +8270,7 @@ pub async fn restore_multiworkspace(
     if let Some(target_id) = state.active_workspace_id {
         window_handle
             .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.set_database_id(window_id);
                 let target_index = multi_workspace
                     .workspaces()
                     .iter()
@@ -8253,14 +8292,6 @@ pub async fn restore_multiworkspace(
             .ok();
     }
 
-    if state.sidebar_open {
-        window_handle
-            .update(cx, |multi_workspace, _, cx| {
-                multi_workspace.open_sidebar(cx);
-            })
-            .ok();
-    }
-
     window_handle
         .update(cx, |_, window, _cx| {
             window.activate_window();
@@ -8299,6 +8330,15 @@ actions!(
         CopyRoomId,
     ]
 );
+
+/// Opens the channel notes for a specific channel by its ID.
+#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = collab)]
+#[serde(deny_unknown_fields)]
+pub struct OpenChannelNotesById {
+    pub channel_id: u64,
+}
+
 actions!(
     zed,
     [
@@ -8478,7 +8518,10 @@ pub fn join_channel(
         let mut active_window = requesting_window.or_else(|| activate_any_workspace_window(cx));
         if active_window.is_none() {
             // no open workspaces, make one to show the error in (blergh)
-            let (window_handle, _) = cx
+            let OpenResult {
+                window: window_handle,
+                ..
+            } = cx
                 .update(|cx| {
                     Workspace::new_local(
                         vec![],
@@ -8734,6 +8777,14 @@ pub struct OpenOptions {
     pub env: Option<HashMap<String, String>>,
 }
 
+/// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`],
+/// or [`Workspace::open_workspace_for_paths`].
+pub struct OpenResult {
+    pub window: WindowHandle<MultiWorkspace>,
+    pub workspace: Entity<Workspace>,
+    pub opened_items: Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
+}
+
 /// Opens a workspace by its database ID, used for restoring empty workspaces with unsaved content.
 pub fn open_workspace_by_id(
     workspace_id: WorkspaceId,
@@ -8853,12 +8904,7 @@ pub fn open_paths(
     app_state: Arc<AppState>,
     open_options: OpenOptions,
     cx: &mut App,
-) -> Task<
-    anyhow::Result<(
-        WindowHandle<MultiWorkspace>,
-        Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
-    )>,
-> {
+) -> Task<anyhow::Result<OpenResult>> {
     let abs_paths = abs_paths.to_vec();
     #[cfg(target_os = "windows")]
     let wsl_path = abs_paths
@@ -8937,7 +8983,7 @@ pub fn open_paths(
                 });
             });
 
-            Ok((existing, open_task))
+            Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task })
         } else {
             let result = cx
                 .update(move |cx| {
@@ -8953,8 +8999,8 @@ pub fn open_paths(
                 })
                 .await;
 
-            if let Ok((ref window_handle, _)) = result {
-                window_handle
+            if let Ok(ref result) = result {
+                result.window
                     .update(cx, |_, window, _cx| {
                         window.activate_window();
                     })
@@ -8966,9 +9012,9 @@ pub fn open_paths(
 
         #[cfg(target_os = "windows")]
         if let Some(util::paths::WslPath{distro, path}) = wsl_path
-            && let Ok((multi_workspace_window, _)) = &result
+            && let Ok(ref result) = result
         {
-            multi_workspace_window
+            result.window
                 .update(cx, move |multi_workspace, _window, cx| {
                     struct OpenInWsl;
                     let workspace = multi_workspace.workspace().clone();
@@ -9015,7 +9061,7 @@ pub fn open_new(
         cx,
     );
     cx.spawn(async move |cx| {
-        let (window, _opened_paths) = task.await?;
+        let OpenResult { window, .. } = task.await?;
         window
             .update(cx, |_, window, _cx| {
                 window.activate_window();
@@ -9957,7 +10003,7 @@ pub fn with_active_or_new_workspace(
 
 #[cfg(test)]
 mod tests {
-    use std::{cell::RefCell, rc::Rc};
+    use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
 
     use super::*;
     use crate::{
@@ -9975,6 +10021,7 @@ mod tests {
     use project::{Project, ProjectEntryId};
     use serde_json::json;
     use settings::SettingsStore;
+    use util::path;
     use util::rel_path::rel_path;
 
     #[gpui::test]
@@ -13533,6 +13580,74 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_toggle_theme_mode_persists_and_updates_active_theme(cx: &mut TestAppContext) {
+        use settings::{ThemeName, ThemeSelection};
+        use theme::SystemAppearance;
+        use zed_actions::theme::ToggleMode;
+
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let settings_fs: Arc<dyn fs::Fs> = fs.clone();
+
+        fs.insert_tree(path!("/root"), json!({ "file.rs": "fn main() {}\n" }))
+            .await;
+
+        // Build a test project and workspace view so the test can invoke
+        // the workspace action handler the same way the UI would.
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        // Seed the settings file with a plain static light theme so the
+        // first toggle always starts from a known persisted state.
+        workspace.update_in(cx, |_workspace, _window, cx| {
+            *SystemAppearance::global_mut(cx) = SystemAppearance(theme::Appearance::Light);
+            settings::update_settings_file(settings_fs.clone(), cx, |settings, _cx| {
+                settings.theme.theme = Some(ThemeSelection::Static(ThemeName("One Light".into())));
+            });
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.run_until_parked();
+
+        // Confirm the initial persisted settings contain the static theme
+        // we just wrote before any toggling happens.
+        let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap();
+        assert!(settings_text.contains(r#""theme": "One Light""#));
+
+        // Toggle once. This should migrate the persisted theme settings
+        // into light/dark slots and enable system mode.
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.toggle_theme_mode(&ToggleMode, window, cx);
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.run_until_parked();
+
+        // 1. Static -> Dynamic
+        // this assertion checks theme changed from static to dynamic.
+        let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap();
+        let parsed: serde_json::Value = settings::parse_json_with_comments(&settings_text).unwrap();
+        assert_eq!(
+            parsed["theme"],
+            serde_json::json!({
+                "mode": "system",
+                "light": "One Light",
+                "dark": "One Dark"
+            })
+        );
+
+        // 2. Toggle again, suppose it will change the mode to light
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.toggle_theme_mode(&ToggleMode, window, cx);
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.run_until_parked();
+
+        let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap();
+        assert!(settings_text.contains(r#""mode": "light""#));
+    }
+
     fn dirty_project_item(id: u64, path: &str, cx: &mut App) -> Entity<TestProjectItem> {
         let item = TestProjectItem::new(id, path, cx);
         item.update(cx, |item, _| {

crates/worktree/Cargo.toml 🔗

@@ -21,7 +21,7 @@ workspace = true
 [features]
 test-support = [
     "gpui/test-support",
-    "http_client/test-support",
+
     "language/test-support",
     "pretty_assertions",
     "settings/test-support",
@@ -63,9 +63,7 @@ ztracing.workspace = true
 [dev-dependencies]
 clock = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
-git2.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
-http_client.workspace = true
 paths = { workspace = true, features = ["test-support"] }
 rand.workspace = true
 rpc = { workspace = true, features = ["test-support"] }

crates/worktree/src/worktree.rs 🔗

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

crates/zed/Cargo.toml 🔗

@@ -2,7 +2,7 @@
 description = "The fast, collaborative code editor."
 edition.workspace = true
 name = "zed"
-version = "0.228.0"
+version = "0.229.0"
 publish.workspace = true
 license = "GPL-3.0-or-later"
 authors = ["Zed Team <hi@zed.dev>"]
@@ -182,7 +182,6 @@ settings.workspace = true
 settings_profile_selector.workspace = true
 settings_ui.workspace = true
 shellexpand.workspace = true
-sidebar.workspace = true
 smol.workspace = true
 snippet_provider.workspace = true
 snippets_ui.workspace = true
@@ -243,7 +242,6 @@ pkg-config = "0.3.22"
 
 [dev-dependencies]
 call = { workspace = true, features = ["test-support"] }
-dap = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 image_viewer = { workspace = true, features = ["test-support"] }
@@ -253,8 +251,6 @@ pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 semver.workspace = true
 terminal_view = { workspace = true, features = ["test-support"] }
-tree-sitter-md.workspace = true
-tree-sitter-rust.workspace = true
 title_bar = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
 image.workspace = true

crates/zed/build.rs 🔗

@@ -43,12 +43,28 @@ fn main() {
         "cargo:rustc-env=TARGET={}",
         std::env::var("TARGET").unwrap()
     );
-    if let Ok(output) = Command::new("git").args(["rev-parse", "HEAD"]).output()
-        && output.status.success()
-    {
-        let git_sha = String::from_utf8_lossy(&output.stdout);
-        let git_sha = git_sha.trim();
 
+    let git_sha = match std::env::var("ZED_COMMIT_SHA").ok() {
+        Some(git_sha) => {
+            // In deterministic build environments such as Nix, we inject the commit sha into the build script.
+            Some(git_sha)
+        }
+        None => {
+            if let Some(output) = Command::new("git")
+                .args(["rev-parse", "HEAD"])
+                .output()
+                .ok()
+                && output.status.success()
+            {
+                let git_sha = String::from_utf8_lossy(&output.stdout);
+                Some(git_sha.trim().to_string())
+            } else {
+                None
+            }
+        }
+    };
+
+    if let Some(git_sha) = git_sha {
         println!("cargo:rustc-env=ZED_COMMIT_SHA={git_sha}");
 
         if let Some(build_identifier) = option_env!("GITHUB_RUN_NUMBER") {

crates/zed/src/main.rs 🔗

@@ -48,7 +48,7 @@ use std::{
     path::{Path, PathBuf},
     process,
     rc::Rc,
-    sync::{Arc, OnceLock},
+    sync::{Arc, LazyLock, OnceLock},
     time::Instant,
 };
 use theme::{ActiveTheme, GlobalTheme, ThemeRegistry};
@@ -657,7 +657,7 @@ fn main() {
         );
 
         copilot_ui::init(&app_state, cx);
-        language_model::init(app_state.client.clone(), cx);
+        language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx);
         language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
         acp_tools::init(cx);
         zed::telemetry_log::init(cx);
@@ -914,7 +914,9 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                 })
                 .detach_and_log_err(cx);
             }
-            OpenRequestKind::AgentPanel { initial_prompt } => {
+            OpenRequestKind::AgentPanel {
+                external_source_prompt,
+            } => {
                 cx.spawn(async move |cx| {
                     let multi_workspace =
                         workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
@@ -923,7 +925,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                         multi_workspace.workspace().update(cx, |workspace, cx| {
                             if let Some(panel) = workspace.focus_panel::<AgentPanel>(window, cx) {
                                 panel.update(cx, |panel, cx| {
-                                    panel.new_external_thread_with_text(initial_prompt, window, cx);
+                                    panel.new_agent_thread_with_external_source_prompt(
+                                        external_source_prompt,
+                                        window,
+                                        cx,
+                                    );
                                 });
                             }
                         });
@@ -979,21 +985,19 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                         })
                         .await?;
 
-                    let thread_metadata = acp_thread::AgentSessionInfo {
-                        session_id,
-                        cwd: None,
-                        title: Some(format!("🔗 {}", response.title).into()),
-                        updated_at: Some(chrono::Utc::now()),
-                        meta: None,
-                    };
-
                     let sharer_username = response.sharer_username.clone();
 
                     multi_workspace.update(cx, |_, window, cx| {
                         workspace.update(cx, |workspace, cx| {
                             if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
                                 panel.update(cx, |panel, cx| {
-                                    panel.open_thread(thread_metadata, window, cx);
+                                    panel.open_thread(
+                                        session_id,
+                                        None,
+                                        Some(format!("🔗 {}", response.title).into()),
+                                        window,
+                                        cx,
+                                    );
                                 });
                                 panel.focus_handle(cx).focus(window, cx);
                             }
@@ -1573,8 +1577,14 @@ fn init_paths() -> HashMap<io::ErrorKind, Vec<&'static Path>> {
     })
 }
 
+pub(crate) static FORCE_CLI_MODE: LazyLock<bool> = LazyLock::new(|| {
+    let env_var = std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some();
+    unsafe { std::env::remove_var(FORCE_CLI_MODE_ENV_VAR_NAME) };
+    env_var
+});
+
 fn stdout_is_a_pty() -> bool {
-    std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() && io::stdout().is_terminal()
+    !*FORCE_CLI_MODE && io::stdout().is_terminal()
 }
 
 #[derive(Parser, Debug)]

crates/zed/src/visual_test_runner.rs 🔗

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

crates/zed/src/zed.rs 🔗

@@ -68,7 +68,6 @@ use settings::{
     initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
     update_settings_file,
 };
-use sidebar::Sidebar;
 use std::time::Duration;
 use std::{
     borrow::Cow,
@@ -163,21 +162,24 @@ pub fn init(cx: &mut App) {
     cx.on_action(quit);
 
     cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
-    let flag = cx.wait_for_flag::<PanicFeatureFlag>();
-    cx.spawn(async |cx| {
-        if cx.update(|cx| ReleaseChannel::global(cx) == ReleaseChannel::Dev) || flag.await {
-            cx.update(|cx| {
-                cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
-                    .on_action(|_: &TestCrash, _| {
-                        unsafe extern "C" {
-                            fn puts(s: *const i8);
-                        }
-                        unsafe {
-                            puts(0xabad1d3a as *const i8);
-                        }
-                    });
-            });
-        };
+
+    cx.observe_flag::<PanicFeatureFlag, _>({
+        let mut added = false;
+        move |enabled, cx| {
+            if added || !enabled {
+                return;
+            }
+            added = true;
+            cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
+                .on_action(|_: &TestCrash, _| {
+                    unsafe extern "C" {
+                        fn puts(s: *const i8);
+                    }
+                    unsafe {
+                        puts(0xabad1d3a as *const i8);
+                    }
+                });
+        }
     })
     .detach();
     cx.on_action(|_: &OpenLog, cx| {
@@ -340,7 +342,7 @@ pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowO
         focus: false,
         show: false,
         kind: WindowKind::Normal,
-        is_movable: true,
+        is_movable: !cfg!(target_os = "macos"),
         display_id: display.map(|display| display.id()),
         window_background: cx.theme().window_background_appearance(),
         app_id: Some(app_id.to_owned()),
@@ -371,15 +373,12 @@ pub fn initialize_workspace(
     })
     .detach();
 
-    cx.observe_new(|multi_workspace: &mut MultiWorkspace, window, cx| {
+    cx.observe_new(|_multi_workspace: &mut MultiWorkspace, window, cx| {
         let Some(window) = window else {
             return;
         };
-        let multi_workspace_handle = cx.entity();
-        let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx));
-        multi_workspace.register_sidebar(sidebar, window, cx);
 
-        let multi_workspace_handle = multi_workspace_handle.downgrade();
+        let multi_workspace_handle = cx.entity().downgrade();
         window.on_window_should_close(cx, move |window, cx| {
             multi_workspace_handle
                 .update(cx, |multi_workspace, cx| {
@@ -491,7 +490,9 @@ pub fn initialize_workspace(
         workspace.set_panels_task(panels_task);
         register_actions(app_state.clone(), workspace, window, cx);
 
-        workspace.focus_handle(cx).focus(window, cx);
+        if !workspace.has_active_modal(window, cx) {
+            workspace.focus_handle(cx).focus(window, cx);
+        }
     })
     .detach();
 }
@@ -1065,37 +1066,54 @@ fn register_actions(
         })
         .register_action({
             let app_state = Arc::downgrade(&app_state);
-            move |_, _: &CloseProject, window, cx| {
+            move |_workspace, _: &CloseProject, window, cx| {
                 let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
                     return;
                 };
                 if let Some(app_state) = app_state.upgrade() {
-                    open_new(
-                        workspace::OpenOptions {
-                            replace_window: Some(window_handle),
-                            ..Default::default()
-                        },
-                        app_state,
-                        cx,
-                        |workspace, window, cx| {
-                            cx.activate(true);
-                            // Create buffer synchronously to avoid flicker
-                            let project = workspace.project().clone();
-                            let buffer = project.update(cx, |project, cx| {
-                                project.create_local_buffer("", None, true, cx)
-                            });
-                            let editor = cx.new(|cx| {
-                                Editor::for_buffer(buffer, Some(project), window, cx)
-                            });
-                            workspace.add_item_to_active_pane(
-                                Box::new(editor),
-                                None,
-                                true,
-                                window,
-                                cx,
-                            );
-                        },
-                    )
+                    cx.spawn_in(window, async move |this, cx| {
+                        let should_continue = this
+                            .update_in(cx, |workspace, window, cx| {
+                                workspace.prepare_to_close(
+                                    CloseIntent::ReplaceWindow,
+                                    window,
+                                    cx,
+                                )
+                            })?
+                            .await?;
+                        if should_continue {
+                            let task = cx.update(|_window, cx| {
+                                open_new(
+                                    workspace::OpenOptions {
+                                        replace_window: Some(window_handle),
+                                        ..Default::default()
+                                    },
+                                    app_state,
+                                    cx,
+                                    |workspace, window, cx| {
+                                        cx.activate(true);
+                                        let project = workspace.project().clone();
+                                        let buffer = project.update(cx, |project, cx| {
+                                            project.create_local_buffer("", None, true, cx)
+                                        });
+                                        let editor = cx.new(|cx| {
+                                            Editor::for_buffer(buffer, Some(project), window, cx)
+                                        });
+                                        workspace.add_item_to_active_pane(
+                                            Box::new(editor),
+                                            None,
+                                            true,
+                                            window,
+                                            cx,
+                                        );
+                                    },
+                                )
+                            })?;
+                            task.await
+                        } else {
+                            Ok(())
+                        }
+                    })
                     .detach_and_log_err(cx);
                 }
             }
@@ -1995,13 +2013,29 @@ fn open_local_file(
 }
 
 fn open_bundled_file(
-    workspace: &Workspace,
+    workspace: &mut Workspace,
     text: Cow<'static, str>,
     title: &'static str,
     language: &'static str,
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
+    let existing = workspace.items_of_type::<Editor>(cx).find(|editor| {
+        editor.read_with(cx, |editor, cx| {
+            editor.read_only(cx)
+                && editor.title(cx).as_ref() == title
+                && editor
+                    .buffer()
+                    .read(cx)
+                    .as_singleton()
+                    .is_some_and(|buffer| buffer.read(cx).file().is_none())
+        })
+    });
+    if let Some(existing) = existing {
+        workspace.activate_item(&existing, true, true, window, cx);
+        return;
+    }
+
     let language = workspace.app_state().languages.language_for_name(language);
     cx.spawn_in(window, async move |workspace, cx| {
         let language = language.await.log_err();
@@ -3425,7 +3459,11 @@ mod tests {
             PathBuf::from(path!("/root/.git/HEAD")),
             PathBuf::from(path!("/root/excluded_dir/ignored_subdir")),
         ];
-        let (opened_workspace, new_items) = cx
+        let workspace::OpenResult {
+            window: opened_workspace,
+            opened_items: new_items,
+            ..
+        } = cx
             .update(|cx| {
                 workspace::open_paths(
                     &paths_to_open,
@@ -4861,6 +4899,7 @@ mod tests {
                 "task",
                 "terminal",
                 "terminal_panel",
+                "theme",
                 "theme_selector",
                 "toast",
                 "toolchain",
@@ -4952,6 +4991,54 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_bundled_files_reuse_existing_editor(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(init);
+
+        let project = Project::test(app_state.fs.clone(), [], cx).await;
+        let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+        cx.update(|cx| {
+            cx.dispatch_action(&OpenDefaultSettings);
+        });
+        cx.run_until_parked();
+
+        let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+        let first_item_id = multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    workspace
+                        .active_item(cx)
+                        .expect("default settings should be open")
+                        .item_id()
+                })
+            })
+            .unwrap();
+
+        cx.update(|cx| {
+            cx.dispatch_action(&OpenDefaultSettings);
+        });
+        cx.run_until_parked();
+
+        let (second_item_id, item_count) = multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    let pane = workspace.active_pane().read(cx);
+                    (
+                        pane.active_item()
+                            .expect("default settings should still be open")
+                            .item_id(),
+                        pane.items_len(),
+                    )
+                })
+            })
+            .unwrap();
+
+        assert_eq!(first_item_id, second_item_id);
+        assert_eq!(item_count, 1);
+    }
+
     #[gpui::test]
     async fn test_bundled_languages(cx: &mut TestAppContext) {
         let fs = fs::FakeFs::new(cx.background_executor.clone());
@@ -5011,7 +5098,7 @@ mod tests {
                 cx,
             );
             image_viewer::init(cx);
-            language_model::init(app_state.client.clone(), cx);
+            language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx);
             language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
             web_search::init(cx);
             git_graph::init(cx);
@@ -5800,7 +5887,9 @@ mod tests {
         //
         //   Window A: workspace for dir1, workspace for dir2
         //   Window B: workspace for dir3
-        let (window_a, _) = cx
+        let workspace::OpenResult {
+            window: window_a, ..
+        } = cx
             .update(|cx| {
                 Workspace::new_local(
                     vec![dir1.into()],
@@ -5824,7 +5913,9 @@ mod tests {
             .expect("failed to open second workspace into window A");
         cx.run_until_parked();
 
-        let (window_b, _) = cx
+        let workspace::OpenResult {
+            window: window_b, ..
+        } = cx
             .update(|cx| {
                 Workspace::new_local(
                     vec![dir3.into()],

crates/zed/src/zed/edit_prediction_registry.rs 🔗

@@ -316,7 +316,7 @@ mod tests {
         let app_state = cx.update(|cx| {
             let app_state = AppState::test(cx);
             client::init(&app_state.client, cx);
-            language_model::init(app_state.client.clone(), cx);
+            language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx);
             editor::init(cx);
             app_state
         });

crates/zed/src/zed/open_listener.rs 🔗

@@ -1,5 +1,6 @@
 use crate::handle_open_request;
 use crate::restore_or_create_workspace;
+use agent_ui::ExternalSourcePrompt;
 use anyhow::{Context as _, Result, anyhow};
 use cli::{CliRequest, CliResponse, ipc::IpcSender};
 use cli::{IpcHandshake, ipc};
@@ -28,7 +29,7 @@ use util::ResultExt;
 use util::paths::PathWithPosition;
 use workspace::PathList;
 use workspace::item::ItemHandle;
-use workspace::{AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation};
+use workspace::{AppState, MultiWorkspace, OpenOptions, OpenResult, SerializedWorkspaceLocation};
 
 #[derive(Default, Debug)]
 pub struct OpenRequest {
@@ -48,7 +49,7 @@ pub enum OpenRequestKind {
         extension_id: String,
     },
     AgentPanel {
-        initial_prompt: Option<String>,
+        external_source_prompt: Option<ExternalSourcePrompt>,
     },
     SharedAgentThread {
         session_id: String,
@@ -110,8 +111,6 @@ impl OpenRequest {
                 this.kind = Some(OpenRequestKind::Extension {
                     extension_id: extension_id.to_string(),
                 });
-            } else if let Some(agent_path) = url.strip_prefix("zed://agent") {
-                this.parse_agent_url(agent_path)
             } else if let Some(session_id_str) = url.strip_prefix("zed://agent/shared/") {
                 if uuid::Uuid::parse_str(session_id_str).is_ok() {
                     this.kind = Some(OpenRequestKind::SharedAgentThread {
@@ -120,6 +119,8 @@ impl OpenRequest {
                 } else {
                     log::error!("Invalid session ID in URL: {}", session_id_str);
                 }
+            } else if let Some(agent_path) = url.strip_prefix("zed://agent") {
+                this.parse_agent_url(agent_path)
             } else if let Some(schema_path) = url.strip_prefix("zed://schemas/") {
                 this.kind = Some(OpenRequestKind::BuiltinJsonSchema {
                     schema_path: schema_path.to_string(),
@@ -164,13 +165,14 @@ impl OpenRequest {
 
     fn parse_agent_url(&mut self, agent_path: &str) {
         // Format: "" or "?prompt=<text>"
-        let initial_prompt = agent_path.strip_prefix('?').and_then(|query| {
+        let external_source_prompt = agent_path.strip_prefix('?').and_then(|query| {
             url::form_urlencoded::parse(query.as_bytes())
                 .find_map(|(key, value)| (key == "prompt").then_some(value))
-                .filter(|s| !s.is_empty())
-                .map(|s| s.into_owned())
+                .and_then(|prompt| ExternalSourcePrompt::new(prompt.as_ref()))
+        });
+        self.kind = Some(OpenRequestKind::AgentPanel {
+            external_source_prompt,
         });
-        self.kind = Some(OpenRequestKind::AgentPanel { initial_prompt });
     }
 
     fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
@@ -343,7 +345,11 @@ pub async fn open_paths_with_positions(
         .map(|path_with_position| path_with_position.path.clone())
         .collect::<Vec<_>>();
 
-    let (multi_workspace, mut items) = cx
+    let OpenResult {
+        window: multi_workspace,
+        opened_items: mut items,
+        ..
+    } = cx
         .update(|cx| workspace::open_paths(&paths, app_state, open_options, cx))
         .await?;
 
@@ -772,6 +778,137 @@ mod tests {
         assert_eq!(request.open_paths, vec!["/"]);
     }
 
+    #[gpui::test]
+    fn test_parse_agent_url(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec!["zed://agent".into()],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::AgentPanel {
+                external_source_prompt,
+            }) => {
+                assert_eq!(external_source_prompt, None);
+            }
+            _ => panic!("Expected AgentPanel kind"),
+        }
+    }
+
+    fn agent_url_with_prompt(prompt: &str) -> String {
+        let mut serializer = url::form_urlencoded::Serializer::new("zed://agent?".to_string());
+        serializer.append_pair("prompt", prompt);
+        serializer.finish()
+    }
+
+    #[gpui::test]
+    fn test_parse_agent_url_with_prompt(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+        let prompt = "Write me a script\nThanks";
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![agent_url_with_prompt(prompt)],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::AgentPanel {
+                external_source_prompt,
+            }) => {
+                assert_eq!(
+                    external_source_prompt
+                        .as_ref()
+                        .map(ExternalSourcePrompt::as_str),
+                    Some("Write me a script\nThanks")
+                );
+            }
+            _ => panic!("Expected AgentPanel kind"),
+        }
+    }
+
+    #[gpui::test]
+    fn test_parse_agent_url_with_empty_prompt(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![agent_url_with_prompt("")],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::AgentPanel {
+                external_source_prompt,
+            }) => {
+                assert_eq!(external_source_prompt, None);
+            }
+            _ => panic!("Expected AgentPanel kind"),
+        }
+    }
+
+    #[gpui::test]
+    fn test_parse_shared_agent_thread_url(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+        let session_id = "123e4567-e89b-12d3-a456-426614174000";
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![format!("zed://agent/shared/{session_id}")],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::SharedAgentThread {
+                session_id: parsed_session_id,
+            }) => {
+                assert_eq!(parsed_session_id, session_id);
+            }
+            _ => panic!("Expected SharedAgentThread kind"),
+        }
+    }
+
+    #[gpui::test]
+    fn test_parse_shared_agent_thread_url_with_invalid_uuid(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec!["zed://agent/shared/not-a-uuid".into()],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        assert!(request.kind.is_none());
+    }
+
     #[gpui::test]
     fn test_parse_git_commit_url(cx: &mut TestAppContext) {
         let _app_state = init_test(cx);

crates/zed_actions/src/lib.rs 🔗

@@ -325,6 +325,12 @@ pub mod feedback {
     );
 }
 
+pub mod theme {
+    use gpui::actions;
+
+    actions!(theme, [ToggleMode]);
+}
+
 pub mod theme_selector {
     use gpui::Action;
     use schemars::JsonSchema;
@@ -469,6 +475,33 @@ pub mod agent {
         /// The base ref that the diff was computed against (e.g. "main").
         pub base_ref: SharedString,
     }
+
+    /// A single merge conflict region extracted from a file.
+    #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)]
+    pub struct ConflictContent {
+        pub file_path: String,
+        pub conflict_text: String,
+        pub ours_branch_name: String,
+        pub theirs_branch_name: String,
+    }
+
+    /// Opens a new agent thread to resolve specific merge conflicts.
+    #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+    #[action(namespace = agent)]
+    #[serde(deny_unknown_fields)]
+    pub struct ResolveConflictsWithAgent {
+        /// Individual conflicts with their full text.
+        pub conflicts: Vec<ConflictContent>,
+    }
+
+    /// Opens a new agent thread to resolve merge conflicts in the given file paths.
+    #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+    #[action(namespace = agent)]
+    #[serde(deny_unknown_fields)]
+    pub struct ResolveConflictedFilesWithAgent {
+        /// File paths with unresolved conflicts (for project-wide resolution).
+        pub conflicted_file_paths: Vec<String>,
+    }
 }
 
 pub mod assistant {

crates/zeta_prompt/src/excerpt_ranges.rs 🔗

@@ -0,0 +1,443 @@
+use std::ops::Range;
+
+use serde::{Deserialize, Serialize};
+
+use crate::estimate_tokens;
+
+/// Pre-computed byte offset ranges within `cursor_excerpt` for different
+/// editable and context token budgets. Allows the server to select the
+/// appropriate ranges for whichever model it uses.
+#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)]
+pub struct ExcerptRanges {
+    /// Editable region computed with a 150-token budget.
+    pub editable_150: Range<usize>,
+    /// Editable region computed with a 180-token budget.
+    pub editable_180: Range<usize>,
+    /// Editable region computed with a 350-token budget.
+    pub editable_350: Range<usize>,
+    /// Editable region computed with a 350-token budget.
+    pub editable_512: Option<Range<usize>>,
+    /// Context boundary when using editable_150 with 350 tokens of additional context.
+    pub editable_150_context_350: Range<usize>,
+    /// Context boundary when using editable_180 with 350 tokens of additional context.
+    pub editable_180_context_350: Range<usize>,
+    /// Context boundary when using editable_350 with 150 tokens of additional context.
+    pub editable_350_context_150: Range<usize>,
+    pub editable_350_context_512: Option<Range<usize>>,
+    pub editable_350_context_1024: Option<Range<usize>>,
+    pub context_4096: Option<Range<usize>>,
+    pub context_8192: Option<Range<usize>>,
+}
+
+/// Builds an `ExcerptRanges` by computing editable and context ranges for each
+/// budget combination, using the syntax-aware logic in
+/// `compute_editable_and_context_ranges`.
+pub fn compute_legacy_excerpt_ranges(
+    cursor_excerpt: &str,
+    cursor_offset: usize,
+    syntax_ranges: &[Range<usize>],
+) -> ExcerptRanges {
+    let compute = |editable_tokens, context_tokens| {
+        compute_editable_and_context_ranges(
+            cursor_excerpt,
+            cursor_offset,
+            syntax_ranges,
+            editable_tokens,
+            context_tokens,
+        )
+    };
+
+    let (editable_150, editable_150_context_350) = compute(150, 350);
+    let (editable_180, editable_180_context_350) = compute(180, 350);
+    let (editable_350, editable_350_context_150) = compute(350, 150);
+    let (editable_512, _) = compute(512, 0);
+    let (_, editable_350_context_512) = compute(350, 512);
+    let (_, editable_350_context_1024) = compute(350, 1024);
+    let (_, context_4096) = compute(350, 4096);
+    let (_, context_8192) = compute(350, 8192);
+
+    ExcerptRanges {
+        editable_150,
+        editable_180,
+        editable_350,
+        editable_512: Some(editable_512),
+        editable_150_context_350,
+        editable_180_context_350,
+        editable_350_context_150,
+        editable_350_context_512: Some(editable_350_context_512),
+        editable_350_context_1024: Some(editable_350_context_1024),
+        context_4096: Some(context_4096),
+        context_8192: Some(context_8192),
+    }
+}
+
+/// Given the cursor excerpt text, cursor offset, and the syntax node ranges
+/// containing the cursor (innermost to outermost), compute the editable range
+/// and context range as byte offset ranges within `cursor_excerpt`.
+///
+/// This is the server-side equivalent of `compute_excerpt_ranges` in
+/// `edit_prediction::cursor_excerpt`, but operates on plain text with
+/// pre-computed syntax boundaries instead of a `BufferSnapshot`.
+pub fn compute_editable_and_context_ranges(
+    cursor_excerpt: &str,
+    cursor_offset: usize,
+    syntax_ranges: &[Range<usize>],
+    editable_token_limit: usize,
+    context_token_limit: usize,
+) -> (Range<usize>, Range<usize>) {
+    let line_starts = compute_line_starts(cursor_excerpt);
+    let cursor_row = offset_to_row(&line_starts, cursor_offset);
+    let max_row = line_starts.len().saturating_sub(1) as u32;
+
+    let editable_range = compute_editable_range_from_text(
+        cursor_excerpt,
+        &line_starts,
+        cursor_row,
+        max_row,
+        syntax_ranges,
+        editable_token_limit,
+    );
+
+    let context_range = expand_context_from_text(
+        cursor_excerpt,
+        &line_starts,
+        max_row,
+        &editable_range,
+        syntax_ranges,
+        context_token_limit,
+    );
+
+    (editable_range, context_range)
+}
+
+fn compute_line_starts(text: &str) -> Vec<usize> {
+    let mut starts = vec![0];
+    for (index, byte) in text.bytes().enumerate() {
+        if byte == b'\n' {
+            starts.push(index + 1);
+        }
+    }
+    starts
+}
+
+fn offset_to_row(line_starts: &[usize], offset: usize) -> u32 {
+    match line_starts.binary_search(&offset) {
+        Ok(row) => row as u32,
+        Err(row) => (row.saturating_sub(1)) as u32,
+    }
+}
+
+fn row_start_offset(line_starts: &[usize], row: u32) -> usize {
+    line_starts.get(row as usize).copied().unwrap_or(0)
+}
+
+fn row_end_offset(text: &str, line_starts: &[usize], row: u32) -> usize {
+    if let Some(&next_start) = line_starts.get(row as usize + 1) {
+        // End before the newline of this row.
+        next_start.saturating_sub(1).min(text.len())
+    } else {
+        text.len()
+    }
+}
+
+fn row_range_to_byte_range(
+    text: &str,
+    line_starts: &[usize],
+    start_row: u32,
+    end_row: u32,
+) -> Range<usize> {
+    let start = row_start_offset(line_starts, start_row);
+    let end = row_end_offset(text, line_starts, end_row);
+    start..end
+}
+
+fn estimate_tokens_for_row_range(
+    text: &str,
+    line_starts: &[usize],
+    start_row: u32,
+    end_row: u32,
+) -> usize {
+    let mut tokens = 0;
+    for row in start_row..end_row {
+        let row_len = row_end_offset(text, line_starts, row)
+            .saturating_sub(row_start_offset(line_starts, row));
+        tokens += estimate_tokens(row_len).max(1);
+    }
+    tokens
+}
+
+fn line_token_count_from_text(text: &str, line_starts: &[usize], row: u32) -> usize {
+    let row_len =
+        row_end_offset(text, line_starts, row).saturating_sub(row_start_offset(line_starts, row));
+    estimate_tokens(row_len).max(1)
+}
+
+/// Returns syntax boundaries (as row ranges) that contain the given row range
+/// and extend beyond it, ordered from smallest to largest.
+fn containing_syntax_boundaries_from_ranges(
+    line_starts: &[usize],
+    syntax_ranges: &[Range<usize>],
+    start_row: u32,
+    end_row: u32,
+) -> Vec<(u32, u32)> {
+    let mut boundaries = Vec::new();
+    let mut last: Option<(u32, u32)> = None;
+
+    // syntax_ranges is innermost to outermost, so iterate in order.
+    for range in syntax_ranges {
+        let node_start_row = offset_to_row(line_starts, range.start);
+        let node_end_row = offset_to_row(line_starts, range.end);
+
+        // Skip nodes that don't extend beyond the current range.
+        if node_start_row >= start_row && node_end_row <= end_row {
+            continue;
+        }
+
+        let rows = (node_start_row, node_end_row);
+        if last == Some(rows) {
+            continue;
+        }
+
+        last = Some(rows);
+        boundaries.push(rows);
+    }
+
+    boundaries
+}
+
+fn compute_editable_range_from_text(
+    text: &str,
+    line_starts: &[usize],
+    cursor_row: u32,
+    max_row: u32,
+    syntax_ranges: &[Range<usize>],
+    token_limit: usize,
+) -> Range<usize> {
+    // Phase 1: Expand symmetrically from cursor using 75% of budget.
+    let initial_budget = (token_limit * 3) / 4;
+    let (mut start_row, mut end_row, mut remaining_tokens) =
+        expand_symmetric(text, line_starts, cursor_row, max_row, initial_budget);
+
+    remaining_tokens += token_limit.saturating_sub(initial_budget);
+
+    let original_start = start_row;
+    let original_end = end_row;
+
+    // Phase 2: Expand to syntax boundaries that fit within budget.
+    let boundaries =
+        containing_syntax_boundaries_from_ranges(line_starts, syntax_ranges, start_row, end_row);
+    for (boundary_start, boundary_end) in &boundaries {
+        let tokens_for_start = if *boundary_start < start_row {
+            estimate_tokens_for_row_range(text, line_starts, *boundary_start, start_row)
+        } else {
+            0
+        };
+        let tokens_for_end = if *boundary_end > end_row {
+            estimate_tokens_for_row_range(text, line_starts, end_row + 1, *boundary_end + 1)
+        } else {
+            0
+        };
+
+        let total_needed = tokens_for_start + tokens_for_end;
+        if total_needed <= remaining_tokens {
+            if *boundary_start < start_row {
+                start_row = *boundary_start;
+            }
+            if *boundary_end > end_row {
+                end_row = *boundary_end;
+            }
+            remaining_tokens = remaining_tokens.saturating_sub(total_needed);
+        } else {
+            break;
+        }
+    }
+
+    // Phase 3: Continue line-wise in the direction we expanded least.
+    let expanded_up = original_start.saturating_sub(start_row);
+    let expanded_down = end_row.saturating_sub(original_end);
+    let prefer_up = expanded_up <= expanded_down;
+
+    (start_row, end_row, _) = expand_linewise(
+        text,
+        line_starts,
+        start_row,
+        end_row,
+        max_row,
+        remaining_tokens,
+        prefer_up,
+    );
+
+    row_range_to_byte_range(text, line_starts, start_row, end_row)
+}
+
+fn expand_context_from_text(
+    text: &str,
+    line_starts: &[usize],
+    max_row: u32,
+    editable_range: &Range<usize>,
+    syntax_ranges: &[Range<usize>],
+    context_token_limit: usize,
+) -> Range<usize> {
+    let mut start_row = offset_to_row(line_starts, editable_range.start);
+    let mut end_row = offset_to_row(line_starts, editable_range.end);
+    let mut remaining_tokens = context_token_limit;
+    let mut did_syntax_expand = false;
+
+    let boundaries =
+        containing_syntax_boundaries_from_ranges(line_starts, syntax_ranges, start_row, end_row);
+    for (boundary_start, boundary_end) in &boundaries {
+        let tokens_for_start = if *boundary_start < start_row {
+            estimate_tokens_for_row_range(text, line_starts, *boundary_start, start_row)
+        } else {
+            0
+        };
+        let tokens_for_end = if *boundary_end > end_row {
+            estimate_tokens_for_row_range(text, line_starts, end_row + 1, *boundary_end + 1)
+        } else {
+            0
+        };
+
+        let total_needed = tokens_for_start + tokens_for_end;
+        if total_needed <= remaining_tokens {
+            if *boundary_start < start_row {
+                start_row = *boundary_start;
+            }
+            if *boundary_end > end_row {
+                end_row = *boundary_end;
+            }
+            remaining_tokens = remaining_tokens.saturating_sub(total_needed);
+            did_syntax_expand = true;
+        } else {
+            break;
+        }
+    }
+
+    // Only expand line-wise if no syntax expansion occurred.
+    if !did_syntax_expand {
+        (start_row, end_row, _) = expand_linewise(
+            text,
+            line_starts,
+            start_row,
+            end_row,
+            max_row,
+            remaining_tokens,
+            true,
+        );
+    }
+
+    row_range_to_byte_range(text, line_starts, start_row, end_row)
+}
+
+fn expand_symmetric(
+    text: &str,
+    line_starts: &[usize],
+    cursor_row: u32,
+    max_row: u32,
+    mut token_budget: usize,
+) -> (u32, u32, usize) {
+    let mut start_row = cursor_row;
+    let mut end_row = cursor_row;
+
+    let cursor_line_tokens = line_token_count_from_text(text, line_starts, cursor_row);
+    token_budget = token_budget.saturating_sub(cursor_line_tokens);
+
+    loop {
+        let can_expand_up = start_row > 0;
+        let can_expand_down = end_row < max_row;
+
+        if token_budget == 0 || (!can_expand_up && !can_expand_down) {
+            break;
+        }
+
+        if can_expand_down {
+            let next_row = end_row + 1;
+            let line_tokens = line_token_count_from_text(text, line_starts, next_row);
+            if line_tokens <= token_budget {
+                end_row = next_row;
+                token_budget = token_budget.saturating_sub(line_tokens);
+            } else {
+                break;
+            }
+        }
+
+        if can_expand_up && token_budget > 0 {
+            let next_row = start_row - 1;
+            let line_tokens = line_token_count_from_text(text, line_starts, next_row);
+            if line_tokens <= token_budget {
+                start_row = next_row;
+                token_budget = token_budget.saturating_sub(line_tokens);
+            } else {
+                break;
+            }
+        }
+    }
+
+    (start_row, end_row, token_budget)
+}
+
+fn expand_linewise(
+    text: &str,
+    line_starts: &[usize],
+    mut start_row: u32,
+    mut end_row: u32,
+    max_row: u32,
+    mut remaining_tokens: usize,
+    prefer_up: bool,
+) -> (u32, u32, usize) {
+    loop {
+        let can_expand_up = start_row > 0;
+        let can_expand_down = end_row < max_row;
+
+        if remaining_tokens == 0 || (!can_expand_up && !can_expand_down) {
+            break;
+        }
+
+        let mut expanded = false;
+
+        if prefer_up {
+            if can_expand_up {
+                let next_row = start_row - 1;
+                let line_tokens = line_token_count_from_text(text, line_starts, next_row);
+                if line_tokens <= remaining_tokens {
+                    start_row = next_row;
+                    remaining_tokens = remaining_tokens.saturating_sub(line_tokens);
+                    expanded = true;
+                }
+            }
+            if can_expand_down && remaining_tokens > 0 {
+                let next_row = end_row + 1;
+                let line_tokens = line_token_count_from_text(text, line_starts, next_row);
+                if line_tokens <= remaining_tokens {
+                    end_row = next_row;
+                    remaining_tokens = remaining_tokens.saturating_sub(line_tokens);
+                    expanded = true;
+                }
+            }
+        } else {
+            if can_expand_down {
+                let next_row = end_row + 1;
+                let line_tokens = line_token_count_from_text(text, line_starts, next_row);
+                if line_tokens <= remaining_tokens {
+                    end_row = next_row;
+                    remaining_tokens = remaining_tokens.saturating_sub(line_tokens);
+                    expanded = true;
+                }
+            }
+            if can_expand_up && remaining_tokens > 0 {
+                let next_row = start_row - 1;
+                let line_tokens = line_token_count_from_text(text, line_starts, next_row);
+                if line_tokens <= remaining_tokens {
+                    start_row = next_row;
+                    remaining_tokens = remaining_tokens.saturating_sub(line_tokens);
+                    expanded = true;
+                }
+            }
+        }
+
+        if !expanded {
+            break;
+        }
+    }
+
+    (start_row, end_row, remaining_tokens)
+}

crates/zeta_prompt/src/multi_region.rs 🔗

@@ -0,0 +1,557 @@
+use anyhow::{Context as _, Result, anyhow};
+
+pub const MARKER_TAG_PREFIX: &str = "<|marker_";
+pub const MARKER_TAG_SUFFIX: &str = "|>";
+const MIN_BLOCK_LINES: usize = 3;
+const MAX_BLOCK_LINES: usize = 8;
+
+pub fn marker_tag(number: usize) -> String {
+    format!("{MARKER_TAG_PREFIX}{number}{MARKER_TAG_SUFFIX}")
+}
+
+/// Compute byte offsets within `editable_text` where marker boundaries should
+/// be placed.
+///
+/// Returns a sorted `Vec<usize>` that always starts with `0` and ends with
+/// `editable_text.len()`. Interior offsets are placed at line boundaries
+/// (right after a `\n`), preferring blank-line boundaries when available and
+/// respecting `MIN_BLOCK_LINES` / `MAX_BLOCK_LINES` constraints.
+pub fn compute_marker_offsets(editable_text: &str) -> Vec<usize> {
+    if editable_text.is_empty() {
+        return vec![0, 0];
+    }
+
+    let mut offsets = vec![0usize];
+    let mut lines_since_last_marker = 0usize;
+    let mut byte_offset = 0usize;
+
+    for line in editable_text.split('\n') {
+        let line_end = byte_offset + line.len() + 1;
+        let is_past_end = line_end > editable_text.len();
+        let actual_line_end = line_end.min(editable_text.len());
+        lines_since_last_marker += 1;
+
+        let is_blank = line.trim().is_empty();
+
+        if !is_past_end && lines_since_last_marker >= MIN_BLOCK_LINES {
+            if is_blank {
+                // Blank-line boundary found. We'll place the marker when we
+                // find the next non-blank line (handled below).
+            } else if lines_since_last_marker >= MAX_BLOCK_LINES {
+                offsets.push(actual_line_end);
+                lines_since_last_marker = 0;
+            }
+        }
+
+        // Non-blank line immediately following blank line(s): split here so
+        // the new block starts with this line.
+        if !is_blank && byte_offset > 0 && lines_since_last_marker >= MIN_BLOCK_LINES {
+            let before = &editable_text[..byte_offset];
+            let has_preceding_blank_line = before
+                .strip_suffix('\n')
+                .map(|stripped| {
+                    let last_line = match stripped.rfind('\n') {
+                        Some(pos) => &stripped[pos + 1..],
+                        None => stripped,
+                    };
+                    last_line.trim().is_empty()
+                })
+                .unwrap_or(false);
+
+            if has_preceding_blank_line {
+                offsets.push(byte_offset);
+                lines_since_last_marker = 1;
+            }
+        }
+
+        byte_offset = actual_line_end;
+
+        // Re-check after blank-line logic since lines_since_last_marker may
+        // have been reset.
+        if !is_past_end && lines_since_last_marker >= MAX_BLOCK_LINES {
+            if *offsets.last().unwrap_or(&0) != actual_line_end {
+                offsets.push(actual_line_end);
+                lines_since_last_marker = 0;
+            }
+        }
+    }
+
+    let end = editable_text.len();
+    if *offsets.last().unwrap_or(&0) != end {
+        offsets.push(end);
+    }
+
+    offsets
+}
+
+/// Write the editable region content with marker tags, inserting the cursor
+/// marker at the given offset within the editable text.
+pub fn write_editable_with_markers(
+    output: &mut String,
+    editable_text: &str,
+    cursor_offset_in_editable: usize,
+    cursor_marker: &str,
+) {
+    let marker_offsets = compute_marker_offsets(editable_text);
+    let mut cursor_placed = false;
+    for (i, &offset) in marker_offsets.iter().enumerate() {
+        let marker_num = i + 1;
+        if !output.is_empty() && !output.ends_with('\n') {
+            output.push('\n');
+        }
+        output.push_str(&marker_tag(marker_num));
+
+        if let Some(&next_offset) = marker_offsets.get(i + 1) {
+            output.push('\n');
+            let block = &editable_text[offset..next_offset];
+            if !cursor_placed
+                && cursor_offset_in_editable >= offset
+                && cursor_offset_in_editable <= next_offset
+            {
+                cursor_placed = true;
+                let cursor_in_block = cursor_offset_in_editable - offset;
+                output.push_str(&block[..cursor_in_block]);
+                output.push_str(cursor_marker);
+                output.push_str(&block[cursor_in_block..]);
+            } else {
+                output.push_str(block);
+            }
+        }
+    }
+}
+
+/// Strip any `<|marker_N|>` tags from `text`.
+///
+/// When a marker tag sits on its own line (followed by `\n`), the trailing
+/// newline is also removed so the surrounding lines stay joined naturally.
+fn strip_marker_tags(text: &str) -> String {
+    let mut result = String::with_capacity(text.len());
+    let mut pos = 0;
+    let bytes = text.as_bytes();
+    while let Some(rel) = text[pos..].find(MARKER_TAG_PREFIX) {
+        result.push_str(&text[pos..pos + rel]);
+        let num_start = pos + rel + MARKER_TAG_PREFIX.len();
+        if let Some(suffix_rel) = text[num_start..].find(MARKER_TAG_SUFFIX) {
+            let mut tag_end = num_start + suffix_rel + MARKER_TAG_SUFFIX.len();
+            if bytes.get(tag_end) == Some(&b'\n') {
+                tag_end += 1;
+            }
+            pos = tag_end;
+        } else {
+            result.push_str(MARKER_TAG_PREFIX);
+            pos = num_start;
+        }
+    }
+    result.push_str(&text[pos..]);
+    result
+}
+
+/// Parse model output that uses the marker format.
+///
+/// Returns `(start_marker_num, end_marker_num, content_between_markers)`.
+/// The leading format-level newline after the start marker is stripped.
+/// Trailing newlines are preserved so blank-line endings in the editable
+/// region are not lost.
+///
+/// Any extra intermediate marker tags that the model may have inserted
+/// between the first and last markers are stripped from the returned content.
+pub fn extract_marker_span(text: &str) -> Result<(usize, usize, String)> {
+    let first_tag_start = text
+        .find(MARKER_TAG_PREFIX)
+        .context("no start marker found in output")?;
+    let first_num_start = first_tag_start + MARKER_TAG_PREFIX.len();
+    let first_num_end = text[first_num_start..]
+        .find(MARKER_TAG_SUFFIX)
+        .map(|i| i + first_num_start)
+        .context("malformed start marker tag")?;
+    let start_num: usize = text[first_num_start..first_num_end]
+        .parse()
+        .context("start marker number is not a valid integer")?;
+    let first_tag_end = first_num_end + MARKER_TAG_SUFFIX.len();
+
+    let last_tag_start = text
+        .rfind(MARKER_TAG_PREFIX)
+        .context("no end marker found in output")?;
+    let last_num_start = last_tag_start + MARKER_TAG_PREFIX.len();
+    let last_num_end = text[last_num_start..]
+        .find(MARKER_TAG_SUFFIX)
+        .map(|i| i + last_num_start)
+        .context("malformed end marker tag")?;
+    let end_num: usize = text[last_num_start..last_num_end]
+        .parse()
+        .context("end marker number is not a valid integer")?;
+
+    if start_num == end_num {
+        return Err(anyhow!(
+            "start and end markers are the same (marker {})",
+            start_num
+        ));
+    }
+
+    let mut content_start = first_tag_end;
+    if text.as_bytes().get(content_start) == Some(&b'\n') {
+        content_start += 1;
+    }
+    let content_end = last_tag_start;
+
+    let content = &text[content_start..content_end.max(content_start)];
+    let content = strip_marker_tags(content);
+    Ok((start_num, end_num, content))
+}
+
+/// Given old editable text and model output with marker span, reconstruct the
+/// full new editable region.
+pub fn apply_marker_span(old_editable: &str, output: &str) -> Result<String> {
+    let (start_num, end_num, raw_new_span) = extract_marker_span(output)?;
+    let marker_offsets = compute_marker_offsets(old_editable);
+
+    let start_idx = start_num
+        .checked_sub(1)
+        .context("marker numbers are 1-indexed")?;
+    let end_idx = end_num
+        .checked_sub(1)
+        .context("marker numbers are 1-indexed")?;
+    let start_byte = *marker_offsets
+        .get(start_idx)
+        .context("start marker number out of range")?;
+    let end_byte = *marker_offsets
+        .get(end_idx)
+        .context("end marker number out of range")?;
+
+    if start_byte > end_byte {
+        return Err(anyhow!("start marker must come before end marker"));
+    }
+
+    let old_span = &old_editable[start_byte..end_byte];
+    let mut new_span = raw_new_span;
+    if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() {
+        new_span.push('\n');
+    }
+    if !old_span.ends_with('\n') && new_span.ends_with('\n') {
+        new_span.pop();
+    }
+
+    let mut result = String::new();
+    result.push_str(&old_editable[..start_byte]);
+    result.push_str(&new_span);
+    result.push_str(&old_editable[end_byte..]);
+
+    Ok(result)
+}
+
+/// Compare old and new editable text, find the minimal marker span that covers
+/// all changes, and encode the result with marker tags.
+pub fn encode_from_old_and_new(
+    old_editable: &str,
+    new_editable: &str,
+    cursor_offset_in_new: Option<usize>,
+    cursor_marker: &str,
+    end_marker: &str,
+    no_edits_marker: &str,
+) -> Result<String> {
+    if old_editable == new_editable {
+        return Ok(format!("{no_edits_marker}{end_marker}"));
+    }
+
+    let marker_offsets = compute_marker_offsets(old_editable);
+
+    let common_prefix = old_editable
+        .bytes()
+        .zip(new_editable.bytes())
+        .take_while(|(a, b)| a == b)
+        .count();
+
+    let old_remaining = old_editable.len() - common_prefix;
+    let new_remaining = new_editable.len() - common_prefix;
+    let max_suffix = old_remaining.min(new_remaining);
+    let common_suffix = old_editable.as_bytes()[old_editable.len() - max_suffix..]
+        .iter()
+        .rev()
+        .zip(
+            new_editable.as_bytes()[new_editable.len() - max_suffix..]
+                .iter()
+                .rev(),
+        )
+        .take_while(|(a, b)| a == b)
+        .count();
+
+    let change_end_in_old = old_editable.len() - common_suffix;
+
+    let start_marker_idx = marker_offsets
+        .iter()
+        .rposition(|&offset| offset <= common_prefix)
+        .unwrap_or(0);
+    let end_marker_idx = marker_offsets
+        .iter()
+        .position(|&offset| offset >= change_end_in_old)
+        .unwrap_or(marker_offsets.len() - 1);
+
+    let old_start = marker_offsets[start_marker_idx];
+    let old_end = marker_offsets[end_marker_idx];
+
+    let new_start = old_start;
+    let new_end = new_editable
+        .len()
+        .saturating_sub(old_editable.len().saturating_sub(old_end));
+
+    let new_span = &new_editable[new_start..new_end];
+
+    let start_marker_num = start_marker_idx + 1;
+    let end_marker_num = end_marker_idx + 1;
+
+    let mut result = String::new();
+    result.push_str(&marker_tag(start_marker_num));
+    result.push('\n');
+
+    if let Some(cursor_offset) = cursor_offset_in_new {
+        if cursor_offset >= new_start && cursor_offset <= new_end {
+            let cursor_in_span = cursor_offset - new_start;
+            let bounded = cursor_in_span.min(new_span.len());
+            result.push_str(&new_span[..bounded]);
+            result.push_str(cursor_marker);
+            result.push_str(&new_span[bounded..]);
+        } else {
+            result.push_str(new_span);
+        }
+    } else {
+        result.push_str(new_span);
+    }
+
+    if !result.ends_with('\n') {
+        result.push('\n');
+    }
+    result.push_str(&marker_tag(end_marker_num));
+    result.push('\n');
+    result.push_str(end_marker);
+
+    Ok(result)
+}
+
+/// Extract the full editable region from text that uses marker tags.
+///
+/// Returns the concatenation of all block contents between the first and last
+/// markers, with intermediate marker tags stripped.
+pub fn extract_editable_region_from_markers(text: &str) -> Option<String> {
+    let first_marker_start = text.find(MARKER_TAG_PREFIX)?;
+
+    let mut markers: Vec<(usize, usize)> = Vec::new();
+    let mut search_start = first_marker_start;
+    while let Some(rel_pos) = text[search_start..].find(MARKER_TAG_PREFIX) {
+        let tag_start = search_start + rel_pos;
+        let num_start = tag_start + MARKER_TAG_PREFIX.len();
+        let num_end = text[num_start..].find(MARKER_TAG_SUFFIX)?;
+        let tag_end = num_start + num_end + MARKER_TAG_SUFFIX.len();
+        markers.push((tag_start, tag_end));
+        search_start = tag_end;
+    }
+
+    if markers.len() < 2 {
+        return None;
+    }
+
+    let (_, first_tag_end) = markers[0];
+    let (last_tag_start, _) = markers[markers.len() - 1];
+
+    let mut content_start = first_tag_end;
+    if text.as_bytes().get(content_start) == Some(&b'\n') {
+        content_start += 1;
+    }
+    let mut content_end = last_tag_start;
+    if content_end > content_start && text.as_bytes().get(content_end - 1) == Some(&b'\n') {
+        content_end -= 1;
+    }
+
+    let raw = &text[content_start..content_end];
+    let result = strip_marker_tags(raw);
+    let result = result.strip_suffix('\n').unwrap_or(&result).to_string();
+    Some(result)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_compute_marker_offsets_small_block() {
+        let text = "aaa\nbbb\nccc\n";
+        let offsets = compute_marker_offsets(text);
+        assert_eq!(offsets, vec![0, text.len()]);
+    }
+
+    #[test]
+    fn test_compute_marker_offsets_blank_line_split() {
+        let text = "aaa\nbbb\nccc\n\nddd\neee\nfff\n";
+        let offsets = compute_marker_offsets(text);
+        assert_eq!(offsets[0], 0);
+        assert!(offsets.contains(&13), "offsets: {:?}", offsets);
+        assert_eq!(*offsets.last().unwrap(), text.len());
+    }
+
+    #[test]
+    fn test_compute_marker_offsets_max_lines_split() {
+        let text = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n";
+        let offsets = compute_marker_offsets(text);
+        assert!(offsets.len() >= 3, "offsets: {:?}", offsets);
+    }
+
+    #[test]
+    fn test_compute_marker_offsets_empty() {
+        let offsets = compute_marker_offsets("");
+        assert_eq!(offsets, vec![0, 0]);
+    }
+
+    #[test]
+    fn test_extract_marker_span() {
+        let text = "<|marker_2|>\n    new content\n<|marker_3|>\n";
+        let (start, end, content) = extract_marker_span(text).unwrap();
+        assert_eq!(start, 2);
+        assert_eq!(end, 3);
+        assert_eq!(content, "    new content\n");
+    }
+
+    #[test]
+    fn test_extract_marker_span_multi_line() {
+        let text = "<|marker_1|>\nline1\nline2\nline3\n<|marker_4|>";
+        let (start, end, content) = extract_marker_span(text).unwrap();
+        assert_eq!(start, 1);
+        assert_eq!(end, 4);
+        assert_eq!(content, "line1\nline2\nline3\n");
+    }
+
+    #[test]
+    fn test_apply_marker_span_basic() {
+        let old = "aaa\nbbb\nccc\n";
+        let output = "<|marker_1|>\naaa\nBBB\nccc\n<|marker_2|>";
+        let result = apply_marker_span(old, output).unwrap();
+        assert_eq!(result, "aaa\nBBB\nccc\n");
+    }
+
+    #[test]
+    fn test_apply_marker_span_preserves_trailing_blank_line() {
+        let old = "/\nresult\n\n";
+        let output = "<|marker_1|>\n//\nresult\n\n<|marker_2|>";
+        let result = apply_marker_span(old, output).unwrap();
+        assert_eq!(result, "//\nresult\n\n");
+    }
+
+    #[test]
+    fn test_encode_no_edits() {
+        let old = "aaa\nbbb\nccc\n";
+        let result = encode_from_old_and_new(
+            old,
+            old,
+            None,
+            "<|user_cursor|>",
+            ">>>>>>> UPDATED\n",
+            "NO_EDITS\n",
+        )
+        .unwrap();
+        assert_eq!(result, "NO_EDITS\n>>>>>>> UPDATED\n");
+    }
+
+    #[test]
+    fn test_encode_with_change() {
+        let old = "aaa\nbbb\nccc\n";
+        let new = "aaa\nBBB\nccc\n";
+        let result = encode_from_old_and_new(
+            old,
+            new,
+            None,
+            "<|user_cursor|>",
+            ">>>>>>> UPDATED\n",
+            "NO_EDITS\n",
+        )
+        .unwrap();
+        assert!(result.contains("<|marker_1|>"));
+        assert!(result.contains("<|marker_2|>"));
+        assert!(result.contains("aaa\nBBB\nccc\n"));
+        assert!(result.ends_with(">>>>>>> UPDATED\n"));
+    }
+
+    #[test]
+    fn test_roundtrip_encode_apply() {
+        let old = "line1\nline2\nline3\n\nline5\nline6\nline7\nline8\nline9\nline10\n";
+        let new = "line1\nline2\nline3\n\nline5\nLINE6\nline7\nline8\nline9\nline10\n";
+        let encoded = encode_from_old_and_new(
+            old,
+            new,
+            None,
+            "<|user_cursor|>",
+            ">>>>>>> UPDATED\n",
+            "NO_EDITS\n",
+        )
+        .unwrap();
+        let output = encoded
+            .strip_suffix(">>>>>>> UPDATED\n")
+            .expect("should have end marker");
+        let reconstructed = apply_marker_span(old, output).unwrap();
+        assert_eq!(reconstructed, new);
+    }
+
+    #[test]
+    fn test_extract_editable_region_from_markers_multi() {
+        let text = "prefix\n<|marker_1|>\naaa\nbbb\n<|marker_2|>\nccc\nddd\n<|marker_3|>\nsuffix";
+        let parsed = extract_editable_region_from_markers(text).unwrap();
+        assert_eq!(parsed, "aaa\nbbb\nccc\nddd");
+    }
+
+    #[test]
+    fn test_extract_editable_region_two_markers() {
+        let text = "<|marker_1|>\none\ntwo three\n<|marker_2|>";
+        let parsed = extract_editable_region_from_markers(text).unwrap();
+        assert_eq!(parsed, "one\ntwo three");
+    }
+
+    #[test]
+    fn test_encode_with_cursor() {
+        let old = "aaa\nbbb\nccc\n";
+        let new = "aaa\nBBB\nccc\n";
+        let result = encode_from_old_and_new(
+            old,
+            new,
+            Some(5),
+            "<|user_cursor|>",
+            ">>>>>>> UPDATED\n",
+            "NO_EDITS\n",
+        )
+        .unwrap();
+        assert!(result.contains("<|user_cursor|>"), "result: {result}");
+        assert!(result.contains("B<|user_cursor|>BB"), "result: {result}");
+    }
+
+    #[test]
+    fn test_extract_marker_span_strips_intermediate_markers() {
+        let text = "<|marker_2|>\nline1\n<|marker_3|>\nline2\n<|marker_4|>";
+        let (start, end, content) = extract_marker_span(text).unwrap();
+        assert_eq!(start, 2);
+        assert_eq!(end, 4);
+        assert_eq!(content, "line1\nline2\n");
+    }
+
+    #[test]
+    fn test_extract_marker_span_strips_multiple_intermediate_markers() {
+        let text = "<|marker_1|>\naaa\n<|marker_2|>\nbbb\n<|marker_3|>\nccc\n<|marker_4|>";
+        let (start, end, content) = extract_marker_span(text).unwrap();
+        assert_eq!(start, 1);
+        assert_eq!(end, 4);
+        assert_eq!(content, "aaa\nbbb\nccc\n");
+    }
+
+    #[test]
+    fn test_apply_marker_span_with_extra_intermediate_marker() {
+        let old = "aaa\nbbb\nccc\n";
+        let output = "<|marker_1|>\naaa\n<|marker_1|>\nBBB\nccc\n<|marker_2|>";
+        let result = apply_marker_span(old, output).unwrap();
+        assert_eq!(result, "aaa\nBBB\nccc\n");
+    }
+
+    #[test]
+    fn test_strip_marker_tags_inline() {
+        assert_eq!(strip_marker_tags("no markers here"), "no markers here");
+        assert_eq!(strip_marker_tags("before<|marker_5|>after"), "beforeafter");
+        assert_eq!(
+            strip_marker_tags("line1\n<|marker_3|>\nline2"),
+            "line1\nline2"
+        );
+    }
+}

crates/zeta_prompt/src/zeta_prompt.rs 🔗

@@ -1,4 +1,7 @@
-use anyhow::Result;
+pub mod excerpt_ranges;
+pub mod multi_region;
+
+use anyhow::{Result, anyhow};
 use serde::{Deserialize, Serialize};
 use std::fmt::Write;
 use std::ops::Range;
@@ -6,6 +9,10 @@ use std::path::Path;
 use std::sync::Arc;
 use strum::{EnumIter, IntoEnumIterator as _, IntoStaticStr};
 
+pub use crate::excerpt_ranges::{
+    ExcerptRanges, compute_editable_and_context_ranges, compute_legacy_excerpt_ranges,
+};
+
 pub const CURSOR_MARKER: &str = "<|user_cursor|>";
 pub const MAX_PROMPT_TOKENS: usize = 4096;
 
@@ -18,31 +25,6 @@ fn estimate_tokens(bytes: usize) -> usize {
     bytes / 3
 }
 
-/// Pre-computed byte offset ranges within `cursor_excerpt` for different
-/// editable and context token budgets. Allows the server to select the
-/// appropriate ranges for whichever model it uses.
-#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)]
-pub struct ExcerptRanges {
-    /// Editable region computed with a 150-token budget.
-    pub editable_150: Range<usize>,
-    /// Editable region computed with a 180-token budget.
-    pub editable_180: Range<usize>,
-    /// Editable region computed with a 350-token budget.
-    pub editable_350: Range<usize>,
-    /// Editable region computed with a 350-token budget.
-    pub editable_512: Option<Range<usize>>,
-    /// Context boundary when using editable_150 with 350 tokens of additional context.
-    pub editable_150_context_350: Range<usize>,
-    /// Context boundary when using editable_180 with 350 tokens of additional context.
-    pub editable_180_context_350: Range<usize>,
-    /// Context boundary when using editable_350 with 150 tokens of additional context.
-    pub editable_350_context_150: Range<usize>,
-    pub editable_350_context_512: Option<Range<usize>>,
-    pub editable_350_context_1024: Option<Range<usize>>,
-    pub context_4096: Option<Range<usize>>,
-    pub context_8192: Option<Range<usize>>,
-}
-
 #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)]
 pub struct ZetaPromptInput {
     pub cursor_path: Arc<Path>,
@@ -51,9 +33,18 @@ pub struct ZetaPromptInput {
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub excerpt_start_row: Option<u32>,
     pub events: Vec<Arc<Event>>,
-    pub related_files: Vec<RelatedFile>,
+    #[serde(default)]
+    pub related_files: Option<Vec<RelatedFile>>,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub active_buffer_diagnostics: Vec<ActiveBufferDiagnostic>,
     /// These ranges let the server select model-appropriate subsets.
     pub excerpt_ranges: ExcerptRanges,
+    /// Byte offset ranges within `cursor_excerpt` for all syntax nodes that
+    /// contain `cursor_offset_in_excerpt`, ordered from innermost to outermost.
+    /// When present, the server uses these to compute editable/context ranges
+    /// instead of `excerpt_ranges`.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub syntax_ranges: Option<Vec<Range<usize>>>,
     /// The name of the edit prediction model experiment to use.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub experiment: Option<String>,
@@ -89,7 +80,9 @@ pub enum ZetaFormat {
     V0211Prefill,
     V0211SeedCoder,
     v0226Hashline,
+    V0304VariableEdit,
     V0304SeedNoEdits,
+    V0306SeedMultiRegions,
 }
 
 impl std::fmt::Display for ZetaFormat {
@@ -179,6 +172,15 @@ pub fn write_event(prompt: &mut String, event: &Event) {
     }
 }
 
+#[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)]
+pub struct ActiveBufferDiagnostic {
+    pub severity: Option<i32>,
+    pub message: String,
+    pub snippet: String,
+    pub snippet_buffer_row_range: Range<u32>,
+    pub diagnostic_range_in_snippet: Range<usize>,
+}
+
 #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)]
 pub struct RelatedFile {
     pub path: Arc<Path>,
@@ -216,7 +218,54 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str]
         ZetaFormat::V0211Prefill => v0211_prefill::special_tokens(),
         ZetaFormat::V0211SeedCoder => seed_coder::special_tokens(),
         ZetaFormat::v0226Hashline => hashline::special_tokens(),
+        ZetaFormat::V0304VariableEdit => v0304_variable_edit::special_tokens(),
         ZetaFormat::V0304SeedNoEdits => seed_coder::special_tokens(),
+        ZetaFormat::V0306SeedMultiRegions => {
+            static TOKENS: &[&str] = &[
+                seed_coder::FIM_SUFFIX,
+                seed_coder::FIM_PREFIX,
+                seed_coder::FIM_MIDDLE,
+                seed_coder::FILE_MARKER,
+                seed_coder::START_MARKER,
+                seed_coder::SEPARATOR,
+                seed_coder::END_MARKER,
+                CURSOR_MARKER,
+                multi_region::MARKER_TAG_PREFIX,
+            ];
+            TOKENS
+        }
+    }
+}
+
+/// Returns the (editable_token_limit, context_token_limit) for a given format.
+pub fn token_limits_for_format(format: ZetaFormat) -> (usize, usize) {
+    match format {
+        ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered => (150, 350),
+        ZetaFormat::V0114180EditableRegion => (180, 350),
+        ZetaFormat::V0120GitMergeMarkers
+        | ZetaFormat::V0131GitMergeMarkersPrefix
+        | ZetaFormat::V0211Prefill
+        | ZetaFormat::V0211SeedCoder
+        | ZetaFormat::v0226Hashline
+        | ZetaFormat::V0306SeedMultiRegions
+        | ZetaFormat::V0304SeedNoEdits => (350, 150),
+        ZetaFormat::V0304VariableEdit => (1024, 0),
+    }
+}
+
+pub fn stop_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] {
+    match format {
+        ZetaFormat::v0226Hashline => &[hashline::NO_EDITS_COMMAND_MARKER],
+        ZetaFormat::V0112MiddleAtEnd
+        | ZetaFormat::V0113Ordered
+        | ZetaFormat::V0114180EditableRegion
+        | ZetaFormat::V0120GitMergeMarkers
+        | ZetaFormat::V0131GitMergeMarkersPrefix
+        | ZetaFormat::V0211Prefill
+        | ZetaFormat::V0211SeedCoder
+        | ZetaFormat::V0304VariableEdit
+        | ZetaFormat::V0306SeedMultiRegions
+        | ZetaFormat::V0304SeedNoEdits => &[],
     }
 }
 
@@ -238,10 +287,19 @@ pub fn excerpt_ranges_for_format(
         | ZetaFormat::V0211Prefill
         | ZetaFormat::V0211SeedCoder
         | ZetaFormat::v0226Hashline
-        | ZetaFormat::V0304SeedNoEdits => (
+        | ZetaFormat::V0304SeedNoEdits
+        | ZetaFormat::V0306SeedMultiRegions => (
             ranges.editable_350.clone(),
             ranges.editable_350_context_150.clone(),
         ),
+        ZetaFormat::V0304VariableEdit => {
+            let context = ranges
+                .editable_350_context_1024
+                .clone()
+                .or(ranges.editable_350_context_512.clone())
+                .unwrap_or_else(|| ranges.editable_350_context_150.clone());
+            (context.clone(), context)
+        }
     }
 }
 
@@ -302,7 +360,56 @@ pub fn write_cursor_excerpt_section_for_format(
             editable_range,
             cursor_offset,
         ),
+        ZetaFormat::V0304VariableEdit => {
+            v0304_variable_edit::write_cursor_excerpt_section(prompt, path, context, cursor_offset)
+        }
+        ZetaFormat::V0306SeedMultiRegions => {
+            prompt.push_str(&build_v0306_cursor_prefix(
+                path,
+                context,
+                editable_range,
+                cursor_offset,
+            ));
+        }
+    }
+}
+
+fn build_v0306_cursor_prefix(
+    path: &Path,
+    context: &str,
+    editable_range: &Range<usize>,
+    cursor_offset: usize,
+) -> String {
+    let mut section = String::new();
+    let path_str = path.to_string_lossy();
+    write!(section, "{}{}\n", seed_coder::FILE_MARKER, path_str).ok();
+
+    section.push_str(&context[..editable_range.start]);
+    section.push_str(seed_coder::START_MARKER);
+
+    let editable_text = &context[editable_range.clone()];
+    let cursor_in_editable = cursor_offset - editable_range.start;
+    multi_region::write_editable_with_markers(
+        &mut section,
+        editable_text,
+        cursor_in_editable,
+        CURSOR_MARKER,
+    );
+
+    if !section.ends_with('\n') {
+        section.push('\n');
     }
+    section.push_str(seed_coder::SEPARATOR);
+    section
+}
+
+fn offset_range_to_row_range(text: &str, range: Range<usize>) -> Range<u32> {
+    let start_row = text[0..range.start].matches('\n').count() as u32;
+    let mut end_row = start_row + text[range.clone()].matches('\n').count() as u32;
+    if !text[..range.end].ends_with('\n') {
+        end_row += 1;
+    }
+    return start_row..end_row;
 }
 
 pub fn format_prompt_with_budget_for_format(
@@ -310,9 +417,25 @@ pub fn format_prompt_with_budget_for_format(
     format: ZetaFormat,
     max_tokens: usize,
 ) -> String {
-    let (context, editable_range, cursor_offset) = resolve_cursor_region(input, format);
+    let (context, editable_range, context_range, cursor_offset) =
+        resolve_cursor_region(input, format);
     let path = &*input.cursor_path;
 
+    let empty_files = Vec::new();
+    let input_related_files = input.related_files.as_deref().unwrap_or(&empty_files);
+    let related_files = if let Some(cursor_excerpt_start_row) = input.excerpt_start_row {
+        let relative_row_range = offset_range_to_row_range(&input.cursor_excerpt, context_range);
+        let row_range = relative_row_range.start + cursor_excerpt_start_row
+            ..relative_row_range.end + cursor_excerpt_start_row;
+        &filter_redundant_excerpts(
+            input_related_files.to_vec(),
+            input.cursor_path.as_ref(),
+            row_range,
+        )
+    } else {
+        input_related_files
+    };
+
     match format {
         ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => {
             seed_coder::format_prompt_with_budget(
@@ -321,7 +444,19 @@ pub fn format_prompt_with_budget_for_format(
                 &editable_range,
                 cursor_offset,
                 &input.events,
-                &input.related_files,
+                related_files,
+                max_tokens,
+            )
+        }
+        ZetaFormat::V0306SeedMultiRegions => {
+            let cursor_prefix =
+                build_v0306_cursor_prefix(path, context, &editable_range, cursor_offset);
+            seed_coder::assemble_fim_prompt(
+                context,
+                &editable_range,
+                &cursor_prefix,
+                &input.events,
+                related_files,
                 max_tokens,
             )
         }
@@ -344,12 +479,13 @@ pub fn format_prompt_with_budget_for_format(
                 "<|file_sep|>",
                 "edit history",
                 budget_after_cursor,
+                max_edit_event_count_for_format(&format),
             );
             let edit_history_tokens = estimate_tokens(edit_history_section.len());
             let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens);
 
             let related_files_section = format_related_files_within_budget(
-                &input.related_files,
+                &related_files,
                 "<|file_sep|>",
                 "",
                 budget_after_edit_history,
@@ -364,6 +500,39 @@ pub fn format_prompt_with_budget_for_format(
     }
 }
 
+pub fn filter_redundant_excerpts(
+    mut related_files: Vec<RelatedFile>,
+    cursor_path: &Path,
+    cursor_row_range: Range<u32>,
+) -> Vec<RelatedFile> {
+    for file in &mut related_files {
+        if file.path.as_ref() == cursor_path {
+            file.excerpts.retain(|excerpt| {
+                excerpt.row_range.start < cursor_row_range.start
+                    || excerpt.row_range.end > cursor_row_range.end
+            });
+        }
+    }
+    related_files.retain(|file| !file.excerpts.is_empty());
+    related_files
+}
+
+pub fn max_edit_event_count_for_format(format: &ZetaFormat) -> usize {
+    match format {
+        ZetaFormat::V0112MiddleAtEnd
+        | ZetaFormat::V0113Ordered
+        | ZetaFormat::V0114180EditableRegion
+        | ZetaFormat::V0120GitMergeMarkers
+        | ZetaFormat::V0131GitMergeMarkersPrefix
+        | ZetaFormat::V0211Prefill
+        | ZetaFormat::V0211SeedCoder
+        | ZetaFormat::v0226Hashline
+        | ZetaFormat::V0304SeedNoEdits
+        | ZetaFormat::V0304VariableEdit
+        | ZetaFormat::V0306SeedMultiRegions => 6,
+    }
+}
+
 pub fn get_prefill_for_format(
     format: ZetaFormat,
     context: &str,
@@ -378,7 +547,8 @@ pub fn get_prefill_for_format(
         | ZetaFormat::V0131GitMergeMarkersPrefix
         | ZetaFormat::V0211SeedCoder
         | ZetaFormat::v0226Hashline
-        | ZetaFormat::V0304SeedNoEdits => String::new(),
+        | ZetaFormat::V0304VariableEdit => String::new(),
+        ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => String::new(),
     }
 }
 
@@ -387,36 +557,14 @@ pub fn output_end_marker_for_format(format: ZetaFormat) -> Option<&'static str>
         ZetaFormat::V0120GitMergeMarkers => Some(v0120_git_merge_markers::END_MARKER),
         ZetaFormat::V0131GitMergeMarkersPrefix => Some(v0131_git_merge_markers_prefix::END_MARKER),
         ZetaFormat::V0211Prefill => Some(v0131_git_merge_markers_prefix::END_MARKER),
-        ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => Some(seed_coder::END_MARKER),
+        ZetaFormat::V0211SeedCoder
+        | ZetaFormat::V0304SeedNoEdits
+        | ZetaFormat::V0306SeedMultiRegions => Some(seed_coder::END_MARKER),
         ZetaFormat::V0112MiddleAtEnd
         | ZetaFormat::V0113Ordered
         | ZetaFormat::V0114180EditableRegion
-        | ZetaFormat::v0226Hashline => None,
-    }
-}
-
-pub fn current_region_markers_for_format(format: ZetaFormat) -> (&'static str, &'static str) {
-    match format {
-        ZetaFormat::V0112MiddleAtEnd => ("<|fim_middle|>current\n", "<|fim_middle|>updated"),
-        ZetaFormat::V0113Ordered
-        | ZetaFormat::V0114180EditableRegion
-        | ZetaFormat::v0226Hashline => ("<|fim_middle|>current\n", "<|fim_suffix|>"),
-        ZetaFormat::V0120GitMergeMarkers
-        | ZetaFormat::V0131GitMergeMarkersPrefix
-        | ZetaFormat::V0211Prefill => (
-            v0120_git_merge_markers::START_MARKER,
-            v0120_git_merge_markers::SEPARATOR,
-        ),
-        ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => {
-            (seed_coder::START_MARKER, seed_coder::SEPARATOR)
-        }
-    }
-}
-
-pub fn clean_extracted_region_for_format(format: ZetaFormat, region: &str) -> String {
-    match format {
-        ZetaFormat::v0226Hashline => hashline::strip_hashline_prefixes(region),
-        _ => region.to_string(),
+        | ZetaFormat::v0226Hashline
+        | ZetaFormat::V0304VariableEdit => None,
     }
 }
 
@@ -430,44 +578,78 @@ pub fn encode_patch_as_output_for_format(
         ZetaFormat::v0226Hashline => {
             hashline::patch_to_edit_commands(old_editable_region, patch, cursor_offset).map(Some)
         }
-        ZetaFormat::V0304SeedNoEdits => Ok(seed_coder::no_edits(patch)),
+        ZetaFormat::V0304VariableEdit => v0304_variable_edit::patch_to_variable_edit_output(
+            old_editable_region,
+            patch,
+            cursor_offset,
+        )
+        .map(Some),
+        ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => {
+            Ok(seed_coder::no_edits(patch))
+        }
         _ => Ok(None),
     }
 }
 
-pub fn output_with_context_for_format(
-    format: ZetaFormat,
-    old_editable_region: &str,
+pub struct ParsedOutput {
+    /// Text that should replace the editable region
+    pub new_editable_region: String,
+    /// The byte range within `cursor_excerpt` that this replacement applies to
+    pub range_in_excerpt: Range<usize>,
+}
+
+/// Parse model output for the given zeta format
+pub fn parse_zeta2_model_output(
     output: &str,
-) -> Result<Option<String>> {
-    match format {
-        ZetaFormat::v0226Hashline => {
+    format: ZetaFormat,
+    prompt_inputs: &ZetaPromptInput,
+) -> Result<ParsedOutput> {
+    let output = match output_end_marker_for_format(format) {
+        Some(marker) => output.strip_suffix(marker).unwrap_or(output),
+        None => output,
+    };
+
+    let (context, editable_range_in_context, context_range, _) =
+        resolve_cursor_region(prompt_inputs, format);
+    let context_start = context_range.start;
+    let old_editable_region = &context[editable_range_in_context.clone()];
+
+    let (range_in_context, output) = match format {
+        ZetaFormat::v0226Hashline => (
+            editable_range_in_context,
             if hashline::output_has_edit_commands(output) {
-                Ok(Some(hashline::apply_edit_commands(
-                    old_editable_region,
-                    output,
-                )))
+                hashline::apply_edit_commands(old_editable_region, output)
             } else {
-                Ok(None)
-            }
-        }
-        ZetaFormat::V0304SeedNoEdits => {
+                output.to_string()
+            },
+        ),
+        ZetaFormat::V0304VariableEdit => v0304_variable_edit::apply_variable_edit(context, output)?,
+        ZetaFormat::V0304SeedNoEdits => (
+            editable_range_in_context,
             if output.starts_with(seed_coder::NO_EDITS) {
-                Ok(Some(old_editable_region.to_owned()))
+                old_editable_region.to_string()
             } else {
-                Ok(None)
-            }
-        }
-        _ => Ok(None),
-    }
-}
+                output.to_string()
+            },
+        ),
+        ZetaFormat::V0306SeedMultiRegions => (
+            editable_range_in_context,
+            if output.starts_with(seed_coder::NO_EDITS) {
+                old_editable_region.to_string()
+            } else {
+                multi_region::apply_marker_span(old_editable_region, output)?
+            },
+        ),
+        _ => (editable_range_in_context, output.to_string()),
+    };
 
-/// Post-processes model output for the given zeta format by stripping format-specific suffixes.
-pub fn clean_zeta2_model_output(output: &str, format: ZetaFormat) -> &str {
-    match output_end_marker_for_format(format) {
-        Some(marker) => output.strip_suffix(marker).unwrap_or(output),
-        None => output,
-    }
+    let range_in_excerpt =
+        range_in_context.start + context_start..range_in_context.end + context_start;
+
+    Ok(ParsedOutput {
+        new_editable_region: output,
+        range_in_excerpt,
+    })
 }
 
 pub fn excerpt_range_for_format(
@@ -480,19 +662,35 @@ pub fn excerpt_range_for_format(
 pub fn resolve_cursor_region(
     input: &ZetaPromptInput,
     format: ZetaFormat,
-) -> (&str, Range<usize>, usize) {
-    let (editable_range, context_range) = excerpt_range_for_format(format, &input.excerpt_ranges);
+) -> (&str, Range<usize>, Range<usize>, usize) {
+    let (editable_range, context_range) = if let Some(syntax_ranges) = &input.syntax_ranges {
+        let (editable_tokens, context_tokens) = token_limits_for_format(format);
+        compute_editable_and_context_ranges(
+            &input.cursor_excerpt,
+            input.cursor_offset_in_excerpt,
+            syntax_ranges,
+            editable_tokens,
+            context_tokens,
+        )
+    } else {
+        excerpt_range_for_format(format, &input.excerpt_ranges)
+    };
     let context_start = context_range.start;
-    let context_text = &input.cursor_excerpt[context_range];
+    let context_text = &input.cursor_excerpt[context_range.clone()];
     let adjusted_editable =
         (editable_range.start - context_start)..(editable_range.end - context_start);
     let adjusted_cursor = input.cursor_offset_in_excerpt - context_start;
 
-    (context_text, adjusted_editable, adjusted_cursor)
+    (
+        context_text,
+        adjusted_editable,
+        context_range,
+        adjusted_cursor,
+    )
 }
 
 pub fn get_prefill(input: &ZetaPromptInput, format: ZetaFormat) -> String {
-    let (context, editable_range, _) = resolve_cursor_region(input, format);
+    let (context, editable_range, _, _) = resolve_cursor_region(input, format);
     get_prefill_for_format(format, context, &editable_range)
 }
 
@@ -501,6 +699,7 @@ fn format_edit_history_within_budget(
     file_marker: &str,
     edit_history_name: &str,
     max_tokens: usize,
+    max_edit_event_count: usize,
 ) -> String {
     let header = format!("{}{}\n", file_marker, edit_history_name);
     let header_tokens = estimate_tokens(header.len());
@@ -511,7 +710,7 @@ fn format_edit_history_within_budget(
     let mut event_strings: Vec<String> = Vec::new();
     let mut total_tokens = header_tokens;
 
-    for event in events.iter().rev() {
+    for event in events.iter().rev().take(max_edit_event_count) {
         let mut event_str = String::new();
         write_event(&mut event_str, event);
         let event_tokens = estimate_tokens(event_str.len());
@@ -952,12 +1151,14 @@ pub mod hashline {
 
     const SET_COMMAND_MARKER: &str = "<|set|>";
     const INSERT_COMMAND_MARKER: &str = "<|insert|>";
+    pub const NO_EDITS_COMMAND_MARKER: &str = "<|no_edits|>";
 
     pub fn special_tokens() -> &'static [&'static str] {
         return &[
             SET_COMMAND_MARKER,
             "<|set_range|>",
             INSERT_COMMAND_MARKER,
+            NO_EDITS_COMMAND_MARKER,
             CURSOR_MARKER,
             "<|file_sep|>",
             "<|fim_prefix|>",
@@ -1051,6 +1252,7 @@ pub mod hashline {
         }
 
         prompt.push_str(END_MARKER);
+        prompt.push('\n');
     }
 
     /// A single edit command parsed from the model output.
@@ -1176,7 +1378,9 @@ pub mod hashline {
     }
 
     pub fn output_has_edit_commands(model_output: &str) -> bool {
-        model_output.contains(SET_COMMAND_MARKER) || model_output.contains(INSERT_COMMAND_MARKER)
+        model_output.contains(SET_COMMAND_MARKER)
+            || model_output.contains(INSERT_COMMAND_MARKER)
+            || model_output.contains(NO_EDITS_COMMAND_MARKER)
     }
 
     /// Apply `<|set|>` and `<|insert|>` edit commands from the model output to the
@@ -1187,6 +1391,13 @@ pub mod hashline {
     ///
     /// Returns the full replacement text for the editable region.
     pub fn apply_edit_commands(editable_region: &str, model_output: &str) -> String {
+        if model_output
+            .trim_start()
+            .starts_with(NO_EDITS_COMMAND_MARKER)
+        {
+            return editable_region.to_string();
+        }
+
         let original_lines: Vec<&str> = editable_region.lines().collect();
         let old_hashes: Vec<u8> = original_lines
             .iter()
@@ -1491,6 +1702,10 @@ pub mod hashline {
             result.pop();
         }
 
+        if result.is_empty() {
+            return Ok(NO_EDITS_COMMAND_MARKER.to_string());
+        }
+
         Ok(result)
     }
 
@@ -1521,7 +1736,8 @@ pub mod hashline {
                     <|fim_middle|>current
                     0:5c|hello<|user_cursor|> world
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "multiline_cursor_on_second_line",
@@ -1536,7 +1752,8 @@ pub mod hashline {
                     1:26|b<|user_cursor|>bb
                     2:29|ccc
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "no_trailing_newline_in_context",
@@ -1550,7 +1767,8 @@ pub mod hashline {
                     0:d9|lin<|user_cursor|>e1
                     1:da|line2
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "leading_newline_in_editable_region",
@@ -1564,7 +1782,8 @@ pub mod hashline {
                     0:00|
                     1:26|a<|user_cursor|>bc
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "with_suffix",
@@ -1578,7 +1797,8 @@ pub mod hashline {
                     0:26|ab<|user_cursor|>c
                     <|fim_suffix|>
                     def
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "unicode_two_byte_chars",
@@ -1591,7 +1811,8 @@ pub mod hashline {
                     <|fim_middle|>current
                     0:1b|hé<|user_cursor|>llo
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "unicode_three_byte_chars",
@@ -1604,7 +1825,8 @@ pub mod hashline {
                     <|fim_middle|>current
                     0:80|日本<|user_cursor|>語
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "unicode_four_byte_chars",
@@ -1617,7 +1839,8 @@ pub mod hashline {
                     <|fim_middle|>current
                     0:6b|a🌍<|user_cursor|>b
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "cursor_at_start_of_region_not_placed",
@@ -1630,7 +1853,8 @@ pub mod hashline {
                     <|fim_middle|>current
                     0:26|abc
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "cursor_at_end_of_line_not_placed",
@@ -1644,7 +1868,8 @@ pub mod hashline {
                     0:26|abc
                     1:2f|def
                     <|fim_suffix|>
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
                 Case {
                     name: "cursor_offset_relative_to_context_not_editable_region",
@@ -1663,7 +1888,8 @@ pub mod hashline {
                     1:26|b<|user_cursor|>bb
                     <|fim_suffix|>
                     suf
-                    <|fim_middle|>updated"},
+                    <|fim_middle|>updated
+                    "},
                 },
             ];
 
@@ -1836,6 +2062,18 @@ pub mod hashline {
                     world
                 "},
                 },
+                Case {
+                    name: "no_edits_command_returns_original",
+                    original: indoc! {"
+                    hello
+                    world
+                "},
+                    model_output: "<|no_edits|>",
+                    expected: indoc! {"
+                    hello
+                    world
+                "},
+                },
                 Case {
                     name: "wrong_hash_set_ignored",
                     original: indoc! {"
@@ -2055,6 +2293,7 @@ pub mod hashline {
             )));
             assert!(!hashline::output_has_edit_commands("just plain text"));
             assert!(!hashline::output_has_edit_commands("NO_EDITS"));
+            assert!(hashline::output_has_edit_commands("<|no_edits|>"));
         }
 
         // ---- hashline::patch_to_edit_commands round-trip tests ----
@@ -2292,35 +2531,47 @@ pub mod hashline {
                     }
                 "#},
                     patch: indoc! {r#"
-                    @@ -1,3 +1,3 @@
-                     fn main() {
-                    -    println!();
-                    +    eprintln!("");
-                     }
-                "#},
+                        @@ -1,3 +1,3 @@
+                        fn main() {
+                        -    println!();
+                        +    eprintln!("");
+                        }
+                    "#},
                     expected_new: indoc! {r#"
-                    fn main() {
-                        eprintln!("<|user_cursor|>");
-                    }
-                "#},
+                        fn main() {
+                            eprintln!("<|user_cursor|>");
+                        }
+                    "#},
                 },
                 Case {
                     name: "non_local_hunk_header_pure_insertion_repro",
                     old: indoc! {"
-                    aaa
-                    bbb
-                "},
+                        aaa
+                        bbb
+                    "},
                     patch: indoc! {"
-                    @@ -20,2 +20,3 @@
-                     aaa
-                    +xxx
-                     bbb
-                "},
+                        @@ -20,2 +20,3 @@
+                        aaa
+                        +xxx
+                        bbb
+                    "},
                     expected_new: indoc! {"
-                    aaa
-                    xxx
-                    bbb
-                "},
+                        aaa
+                        xxx
+                        bbb
+                    "},
+                },
+                Case {
+                    name: "empty_patch_produces_no_edits_marker",
+                    old: indoc! {"
+                        aaa
+                        bbb
+                    "},
+                    patch: "@@ -20,2 +20,3 @@\n",
+                    expected_new: indoc! {"
+                        aaa
+                        bbb
+                    "},
                 },
             ];
 
@@ -2434,9 +2685,27 @@ pub mod seed_coder {
         related_files: &[RelatedFile],
         max_tokens: usize,
     ) -> String {
-        let suffix_section = build_suffix_section(context, editable_range);
         let cursor_prefix_section =
             build_cursor_prefix_section(path, context, editable_range, cursor_offset);
+        assemble_fim_prompt(
+            context,
+            editable_range,
+            &cursor_prefix_section,
+            events,
+            related_files,
+            max_tokens,
+        )
+    }
+
+    pub fn assemble_fim_prompt(
+        context: &str,
+        editable_range: &Range<usize>,
+        cursor_prefix_section: &str,
+        events: &[Arc<Event>],
+        related_files: &[RelatedFile],
+        max_tokens: usize,
+    ) -> String {
+        let suffix_section = build_suffix_section(context, editable_range);
 
         let suffix_tokens = estimate_tokens(suffix_section.len());
         let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len());
@@ -2447,6 +2716,7 @@ pub mod seed_coder {
             FILE_MARKER,
             "edit_history",
             budget_after_cursor,
+            max_edit_event_count_for_format(&ZetaFormat::V0211SeedCoder),
         );
         let edit_history_tokens = estimate_tokens(edit_history_section.len());
         let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens);
@@ -2469,7 +2739,7 @@ pub mod seed_coder {
         if !edit_history_section.is_empty() {
             prompt.push('\n');
         }
-        prompt.push_str(&cursor_prefix_section);
+        prompt.push_str(cursor_prefix_section);
         prompt.push_str(FIM_MIDDLE);
         prompt
     }
@@ -2518,59 +2788,1068 @@ pub mod seed_coder {
     }
 }
 
-/// The zeta1 prompt format
-pub mod zeta1 {
+pub mod v0304_variable_edit {
+    //! A prompt format with no fixed editable region. The entire context is shown
+    //! to the model, and it chooses which text to replace by outputting surrounding
+    //! context lines with `<|fim_middle|>` and `<|fim_suffix|>` delimiting the new
+    //! text.
+    //!
+    //! Example prompt:
+    //!
+    //! <|file_sep|>path/to/file.py
+    //! zero
+    //! one
+    //! two
+    //! three<|user_cursor|>
+    //! four
+    //! five
+    //! <|fim_prefix|>
+    //
+    //! Expected output (model generates):
+    //!
+    //! two
+    //! <|fim_middle|>
+    //! THREE
+    //! <|fim_suffix|>
+    //! four
+    //!
+    //! The output means: find "two\n...\nfour" in the context, and replace
+    //! everything between "two\n" and "four" with "THREE\n".
+
     use super::*;
-    use std::fmt::Write;
 
-    pub const CURSOR_MARKER: &str = "<|user_cursor_is_here|>";
-    pub const START_OF_FILE_MARKER: &str = "<|start_of_file|>";
-    pub const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>";
-    pub const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>";
+    pub fn special_tokens() -> &'static [&'static str] {
+        &[
+            "<|fim_prefix|>",
+            "<|fim_suffix|>",
+            "<|fim_middle|>",
+            "<|file_sep|>",
+            CURSOR_MARKER,
+        ]
+    }
 
-    const INSTRUCTION_HEADER: &str = concat!(
-        "### Instruction:\n",
-        "You are a code completion assistant and your task is to analyze user edits and then rewrite an ",
-        "excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking ",
-        "into account the cursor location.\n\n",
-        "### User Edits:\n\n"
-    );
-    const EXCERPT_HEADER: &str = "\n\n### User Excerpt:\n\n";
-    const RESPONSE_HEADER: &str = "\n\n### Response:\n";
+    pub fn write_cursor_excerpt_section(
+        prompt: &mut String,
+        path: &Path,
+        context: &str,
+        cursor_offset: usize,
+    ) {
+        let path_str = path.to_string_lossy();
+        write!(prompt, "<|file_sep|>{}\n", path_str).ok();
 
-    /// Formats a complete zeta1 prompt from the input events and excerpt.
-    pub fn format_zeta1_prompt(input_events: &str, input_excerpt: &str) -> String {
-        let mut prompt = String::with_capacity(
-            INSTRUCTION_HEADER.len()
-                + input_events.len()
-                + EXCERPT_HEADER.len()
-                + input_excerpt.len()
-                + RESPONSE_HEADER.len(),
-        );
-        prompt.push_str(INSTRUCTION_HEADER);
-        prompt.push_str(input_events);
-        prompt.push_str(EXCERPT_HEADER);
-        prompt.push_str(input_excerpt);
-        prompt.push_str(RESPONSE_HEADER);
-        prompt
+        prompt.push_str(&context[..cursor_offset]);
+        prompt.push_str(CURSOR_MARKER);
+        prompt.push_str(&context[cursor_offset..]);
+        if !prompt.ends_with('\n') {
+            prompt.push('\n');
+        }
+        prompt.push_str("<|fim_prefix|>\n")
     }
 
-    /// Formats a complete zeta1 prompt from a `ZetaPromptInput` using the given
-    /// editable and context byte-offset ranges within `cursor_excerpt`.
-    pub fn format_zeta1_from_input(
-        input: &ZetaPromptInput,
-        editable_range: Range<usize>,
-        context_range: Range<usize>,
-    ) -> String {
-        let events = format_zeta1_events(&input.events);
-        let excerpt = format_zeta1_excerpt(input, editable_range, context_range);
-        format_zeta1_prompt(&events, &excerpt)
-    }
+    /// Apply a variable-edit model output to the original context text.
+    ///
+    /// The model output has the form:
+    ///
+    /// - prefix context lines
+    /// - `<|fim_middle|>`
+    /// - new text
+    /// - `<|fim_suffix|>`
+    /// - suffix context lines
+    ///
+    /// We locate the prefix/suffix context lines in the original text and replace
+    /// everything between them with the new text.
+    pub fn apply_variable_edit(
+        context: &str,
+        model_output: &str,
+    ) -> Result<(Range<usize>, String)> {
+        let (prefix_context, rest) = model_output
+            .split_once("<|fim_middle|>\n")
+            .or_else(|| model_output.split_once("<|fim_middle|>"))
+            .ok_or_else(|| anyhow::anyhow!("missing <|fim_middle|> in model output"))?;
+
+        let (new_text, suffix_context) = rest
+            .split_once("<|fim_suffix|>\n")
+            .or_else(|| rest.split_once("<|fim_suffix|>"))
+            .unwrap_or((rest, ""));
+
+        let suffix_context = if prefix_context.is_empty() && !suffix_context.is_empty() {
+            suffix_context.strip_prefix('\n').unwrap_or(suffix_context)
+        } else {
+            suffix_context
+        };
 
-    /// Formats events in zeta1 style (oldest first).
+        let prefix_offset = find_substring_at_line_boundary(context, prefix_context)
+            .ok_or_else(|| anyhow!("could not locate prefix lines"))?
+            + prefix_context.len();
+        let suffix_offset = if suffix_context.is_empty() {
+            context.len()
+        } else {
+            find_substring_at_line_boundary(&context[prefix_offset..], suffix_context)
+                .ok_or_else(|| anyhow!("could not locate suffix lines"))?
+                + prefix_offset
+        };
+
+        let edit_range = prefix_offset..suffix_offset;
+        return Ok((edit_range, new_text.to_string()));
+    }
+
+    fn find_substring_at_line_boundary(haystack: &str, needle: &str) -> Option<usize> {
+        if needle.is_empty() {
+            return Some(0);
+        }
+
+        haystack.match_indices(needle).find_map(|(offset, _)| {
+            let matched_line_start = offset == 0 || haystack[..offset].ends_with('\n');
+            matched_line_start.then_some(offset)
+        })
+    }
+
+    /// Convert a unified diff patch into the variable-edit output format.
+    ///
+    /// Parses `patch` as a unified diff against `old_text` and produces model
+    /// output with context lines surrounding `<|fim_middle|>` / `<|fim_suffix|>`
+    /// delimiters. The diff is resolved by content matching rather than line
+    /// numbers.
+    pub fn patch_to_variable_edit_output(
+        old_text: &str,
+        patch: &str,
+        cursor_offset: Option<usize>,
+    ) -> Result<String> {
+        // Parse the unified diff into hunks. Each hunk has an `old_context`
+        // string (context + deleted lines interleaved in order) and a list of
+        // edits expressed as byte ranges within that context plus replacement
+        // text.
+        let hunks = parse_hunks(patch);
+        if hunks.is_empty() {
+            return Ok(String::new());
+        }
+
+        // Apply each hunk by finding its old_context in the text and
+        // performing the edits. We search forward from where the previous
+        // hunk ended so that hunks are applied in order.
+        let mut new_text = old_text.to_string();
+        let mut search_from: usize = 0;
+        let mut first_hunk_pos: Option<usize> = None;
+
+        for hunk in &hunks {
+            let context_pos = new_text[search_from..]
+                .find(&hunk.old_context)
+                .map(|pos| pos + search_from)
+                .ok_or_else(|| anyhow::anyhow!("could not locate hunk context in text"))?;
+
+            if first_hunk_pos.is_none() {
+                first_hunk_pos = Some(context_pos);
+            }
+
+            // Apply edits in reverse order so byte offsets remain valid.
+            for edit in hunk.edits.iter().rev() {
+                let abs_start = context_pos + edit.range.start;
+                let abs_end = context_pos + edit.range.end;
+                new_text.replace_range(abs_start..abs_end, &edit.text);
+            }
+
+            // Advance past this hunk's region in the (now modified) text.
+            let new_region_len: usize =
+                hunk.edits.iter().fold(hunk.old_context.len(), |len, edit| {
+                    len + edit.text.len() - (edit.range.end - edit.range.start)
+                });
+            search_from = context_pos + new_region_len;
+        }
+
+        // Now we have old_text and new_text. Find the changed line range by
+        // comparing them.
+        let old_lines: Vec<&str> = old_text.lines().collect();
+        let new_lines: Vec<&str> = new_text.lines().collect();
+
+        // Find first differing line.
+        let first_changed_row = old_lines
+            .iter()
+            .zip(new_lines.iter())
+            .position(|(a, b)| a != b)
+            .unwrap_or_else(|| old_lines.len().min(new_lines.len()));
+
+        // Find last differing line (from the end).
+        let max_suffix = old_lines.len().min(new_lines.len()) - first_changed_row;
+        let common_suffix = old_lines
+            .iter()
+            .rev()
+            .zip(new_lines.iter().rev())
+            .take(max_suffix)
+            .take_while(|(a, b)| a == b)
+            .count();
+
+        let old_end = old_lines.len() - common_suffix;
+        let new_end = new_lines.len() - common_suffix;
+
+        if first_changed_row == old_end && first_changed_row == new_end {
+            return Ok(String::new());
+        }
+
+        // Build the replacement text from new_lines[first_diff..new_end].
+        let mut merged_new_text = String::new();
+        for line in &new_lines[first_changed_row..new_end] {
+            merged_new_text.push_str(line);
+            merged_new_text.push('\n');
+        }
+
+        // cursor_offset is relative to the first hunk's new content in
+        // new_text. Translate it to an offset within merged_new_text, which
+        // only contains lines first_diff..new_end of new_text.
+        if let Some(hunk_offset) = cursor_offset {
+            let hunk_start = first_hunk_pos.unwrap_or(0);
+            let absolute_pos = hunk_start + hunk_offset;
+
+            // Byte offset where first_diff starts in new_text.
+            let merged_start: usize = new_lines[..first_changed_row]
+                .iter()
+                .map(|line| line.len() + 1)
+                .sum();
+
+            if absolute_pos >= merged_start {
+                let relative_offset = absolute_pos - merged_start;
+                if relative_offset <= merged_new_text.len() {
+                    merged_new_text.insert_str(relative_offset, CURSOR_MARKER);
+                }
+            }
+        }
+
+        // Build output with 2 lines of context above and below.
+        let context_lines_count = 2;
+        let mut prefix_start = first_changed_row.saturating_sub(context_lines_count);
+        let mut suffix_end = (old_end + context_lines_count).min(old_lines.len());
+
+        fn count_matches(line_range: Range<usize>, lines: &[&str]) -> usize {
+            let pattern = &lines[line_range];
+            let pattern_len = pattern.len();
+
+            let mut count = 0;
+            for offset in 0..=lines.len() - pattern_len {
+                if &lines[offset..offset + pattern_len] == pattern {
+                    count += 1;
+                }
+            }
+            count
+        }
+
+        // Expand prefix and suffix until they are unique
+        while prefix_start > 0 {
+            if count_matches(prefix_start..first_changed_row, &old_lines) > 1 {
+                prefix_start -= 1;
+            } else {
+                break;
+            }
+        }
+        while suffix_end < old_lines.len() {
+            if count_matches(old_end..suffix_end, &old_lines) > 1 {
+                suffix_end += 1;
+            } else {
+                break;
+            }
+        }
+
+        let mut output = String::new();
+        for line in &old_lines[prefix_start..first_changed_row] {
+            output.push_str(line);
+            output.push('\n');
+        }
+        output.push_str("<|fim_middle|>\n");
+        output.push_str(&merged_new_text);
+        output.push_str("<|fim_suffix|>\n");
+        for line in &old_lines[old_end..suffix_end] {
+            output.push_str(line);
+            output.push('\n');
+        }
+
+        Ok(output)
+    }
+
+    struct ParsedHunk {
+        old_context: String,
+        edits: Vec<ParsedEdit>,
+    }
+
+    struct ParsedEdit {
+        range: Range<usize>,
+        text: String,
+    }
+
+    /// Parse a unified diff into content-based hunks. Each hunk contains an
+    /// `old_context` string (context lines + deleted lines, which together
+    /// form the text that should be found in the original) and a list of edits
+    /// expressed as byte ranges within that context.
+    fn parse_hunks(patch: &str) -> Vec<ParsedHunk> {
+        let mut hunks = Vec::new();
+        let mut current: Option<ParsedHunk> = None;
+
+        for line in patch.lines() {
+            if line.starts_with("@@") {
+                if let Some(hunk) = current.take() {
+                    if !hunk.old_context.is_empty() || !hunk.edits.is_empty() {
+                        hunks.push(hunk);
+                    }
+                }
+                current = Some(ParsedHunk {
+                    old_context: String::new(),
+                    edits: Vec::new(),
+                });
+            } else if line.starts_with("---") || line.starts_with("+++") {
+                continue;
+            } else if let Some(hunk) = &mut current {
+                if let Some(added) = line.strip_prefix('+') {
+                    let pos = hunk.old_context.len();
+                    if let Some(last_edit) = hunk.edits.last_mut() {
+                        if last_edit.range.end == pos {
+                            writeln!(&mut last_edit.text, "{added}").ok();
+                            continue;
+                        }
+                    }
+                    hunk.edits.push(ParsedEdit {
+                        range: pos..pos,
+                        text: format!("{added}\n"),
+                    });
+                } else if let Some(removed) = line.strip_prefix('-') {
+                    let start = hunk.old_context.len();
+                    writeln!(&mut hunk.old_context, "{removed}").ok();
+                    let end = hunk.old_context.len();
+                    if let Some(last_edit) = hunk.edits.last_mut() {
+                        if last_edit.range.end == start {
+                            last_edit.range.end = end;
+                            continue;
+                        }
+                    }
+                    hunk.edits.push(ParsedEdit {
+                        range: start..end,
+                        text: String::new(),
+                    });
+                } else {
+                    let ctx = line.strip_prefix(' ').unwrap_or(line);
+                    writeln!(&mut hunk.old_context, "{ctx}").ok();
+                }
+            }
+        }
+
+        if let Some(hunk) = current {
+            if !hunk.old_context.is_empty() || !hunk.edits.is_empty() {
+                hunks.push(hunk);
+            }
+        }
+
+        hunks
+    }
+
+    #[cfg(test)]
+    mod tests {
+        use super::*;
+        use indoc::indoc;
+
+        #[test]
+        fn test_apply_variable_edit() {
+            struct Case {
+                name: &'static str,
+                original: &'static str,
+                model_output: &'static str,
+                expected: &'static str,
+            }
+
+            let cases = [
+                Case {
+                    name: "simple_single_line_replacement",
+                    original: indoc! {"
+                        zero
+                        one
+                        two
+                        three
+                        four
+                        five
+                    "},
+                    model_output: indoc! {"
+                        two
+                        <|fim_middle|>
+                        THREE
+                        <|fim_suffix|>
+                        four
+                    "},
+                    expected: indoc! {"
+                        zero
+                        one
+                        two
+                        THREE
+                        four
+                        five
+                    "},
+                },
+                Case {
+                    name: "multi_line_replacement",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                        d
+                        e
+                    "},
+                    model_output: indoc! {"
+                        a
+                        <|fim_middle|>
+                        B
+                        C
+                        D
+                        <|fim_suffix|>
+                        e
+                    "},
+                    expected: indoc! {"
+                        a
+                        B
+                        C
+                        D
+                        e
+                    "},
+                },
+                Case {
+                    name: "insertion_between_existing_lines",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                    "},
+                    model_output: indoc! {"
+                        a
+                        <|fim_middle|>
+                        X
+                        <|fim_suffix|>
+                        b
+                    "},
+                    expected: indoc! {"
+                        a
+                        X
+                        b
+                        c
+                    "},
+                },
+                Case {
+                    name: "deletion",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                        d
+                    "},
+                    model_output: indoc! {"
+                        a
+                        <|fim_middle|>
+                        <|fim_suffix|>
+                        c
+                    "},
+                    expected: indoc! {"
+                        a
+                        c
+                        d
+                    "},
+                },
+                Case {
+                    name: "replacement_at_start_no_prefix_context",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                    "},
+                    model_output: indoc! {"
+                        <|fim_middle|>
+                        X
+                        <|fim_suffix|>
+                        b
+                    "},
+                    expected: indoc! {"
+                        X
+                        b
+                        c
+                    "},
+                },
+                Case {
+                    name: "replacement_at_end_no_suffix_context",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                    "},
+                    model_output: indoc! {"
+                        b
+                        <|fim_middle|>
+                        Z
+                        <|fim_suffix|>
+                    "},
+                    expected: indoc! {"
+                        a
+                        b
+                        Z
+                    "},
+                },
+                Case {
+                    name: "context_with_trailing_newline_is_preserved",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                    "},
+                    model_output: indoc! {"
+                        a
+                        <|fim_middle|>
+                        B
+                        <|fim_suffix|>
+                        c
+                    "},
+                    expected: indoc! {"
+                        a
+                        B
+                        c
+                    "},
+                },
+                Case {
+                    name: "cursor_marker_passes_through_untouched",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                    "},
+                    model_output: indoc! {"
+                        a
+                        <|fim_middle|>
+                        B<|user_cursor|>B
+                        <|fim_suffix|>
+                        c
+                    "},
+                    expected: indoc! {"
+                        a
+                        B<|user_cursor|>B
+                        c
+                    "},
+                },
+                Case {
+                    name: "multiple_prefix_context_lines",
+                    original: indoc! {"
+                        a
+                        b
+                        c
+                        d
+                        e
+                    "},
+                    model_output: indoc! {"
+                        b
+                        c
+                        <|fim_middle|>
+                        D
+                        <|fim_suffix|>
+                        e
+                    "},
+                    expected: indoc! {"
+                        a
+                        b
+                        c
+                        D
+                        e
+                    "},
+                },
+            ];
+
+            for case in cases {
+                let (edit_range, replacement) =
+                    apply_variable_edit(case.original, case.model_output).unwrap();
+                let mut edited = case.original.to_string();
+                edited.replace_range(edit_range, &replacement);
+                assert_eq!(edited, case.expected, "{}", case.name);
+            }
+        }
+
+        #[test]
+        fn test_patch_to_variable_edit() {
+            struct Case {
+                name: &'static str,
+                old: &'static str,
+                patch: &'static str,
+                cursor_offset: Option<usize>,
+                expected_variable_edit: &'static str,
+                expected_after_apply: &'static str,
+            }
+
+            let cases = [
+                Case {
+                    name: "simple_replacement",
+                    old: indoc! {"
+                        zero
+                        one
+                        two
+                        three
+                        four
+                        five
+                    "},
+                    patch: indoc! {"
+                        @@ -3,3 +3,3 @@
+                         two
+                        -three
+                        +THREE
+                         four
+                    "},
+                    cursor_offset: None,
+                    expected_variable_edit: indoc! {"
+                        one
+                        two
+                        <|fim_middle|>
+                        THREE
+                        <|fim_suffix|>
+                        four
+                        five
+                    "},
+                    expected_after_apply: indoc! {"
+                        zero
+                        one
+                        two
+                        THREE
+                        four
+                        five
+                    "},
+                },
+                Case {
+                    name: "insertion",
+                    old: indoc! {"
+                        a
+                        b
+                        c
+                        d
+                        e
+                    "},
+                    patch: indoc! {"
+                        @@ -2,0 +3,1 @@
+                         b
+                        +X
+                         c
+                    "},
+                    cursor_offset: None,
+                    expected_variable_edit: indoc! {"
+                        a
+                        b
+                        <|fim_middle|>
+                        X
+                        <|fim_suffix|>
+                        c
+                        d
+                    "},
+                    expected_after_apply: indoc! {"
+                        a
+                        b
+                        X
+                        c
+                        d
+                        e
+                    "},
+                },
+                Case {
+                    name: "deletion",
+                    old: indoc! {"
+                        a
+                        b
+                        c
+                        d
+                        e
+                    "},
+                    patch: indoc! {"
+                        @@ -2,3 +2,2 @@
+                         b
+                        -c
+                         d
+                    "},
+                    cursor_offset: None,
+                    expected_variable_edit: indoc! {"
+                        a
+                        b
+                        <|fim_middle|>
+                        <|fim_suffix|>
+                        d
+                        e
+                    "},
+                    expected_after_apply: indoc! {"
+                        a
+                        b
+                        d
+                        e
+                    "},
+                },
+                Case {
+                    name: "edit_near_start",
+                    old: indoc! {"
+                        first
+                        second
+                        third
+                        fourth
+                    "},
+                    patch: indoc! {"
+                        @@ -1,1 +1,1 @@
+                        -first
+                        +FIRST
+                    "},
+                    cursor_offset: None,
+                    expected_variable_edit: indoc! {"
+                        <|fim_middle|>
+                        FIRST
+                        <|fim_suffix|>
+                        second
+                        third
+                    "},
+                    expected_after_apply: indoc! {"
+                        FIRST
+                        second
+                        third
+                        fourth
+                    "},
+                },
+                Case {
+                    name: "edit_near_end",
+                    old: indoc! {"
+                        first
+                        second
+                        third
+                        fourth
+                    "},
+                    patch: indoc! {"
+                        @@ -4,1 +4,1 @@
+                        -fourth
+                        +FOURTH
+                    "},
+                    cursor_offset: None,
+                    expected_variable_edit: indoc! {"
+                        second
+                        third
+                        <|fim_middle|>
+                        FOURTH
+                        <|fim_suffix|>
+                    "},
+                    expected_after_apply: indoc! {"
+                        first
+                        second
+                        third
+                        FOURTH
+                    "},
+                },
+                Case {
+                    name: "cursor_at_start_of_replacement",
+                    old: indoc! {"
+                        zero
+                        one
+                        two
+                        three
+                        four
+                        five
+                    "},
+                    patch: indoc! {"
+                        @@ -3,3 +3,3 @@
+                         two
+                        -three
+                        +THREE
+                         four
+                    "},
+                    cursor_offset: Some(4),
+                    expected_variable_edit: indoc! {"
+                        one
+                        two
+                        <|fim_middle|>
+                        <|user_cursor|>THREE
+                        <|fim_suffix|>
+                        four
+                        five
+                    "},
+                    expected_after_apply: indoc! {"
+                        zero
+                        one
+                        two
+                        <|user_cursor|>THREE
+                        four
+                        five
+                    "},
+                },
+                Case {
+                    name: "cursor_in_middle_of_replacement",
+                    old: indoc! {"
+                        zero
+                        one
+                        two
+                        three
+                        four
+                        five
+                    "},
+                    patch: indoc! {"
+                        @@ -3,3 +3,3 @@
+                         two
+                        -three
+                        +THREE
+                         four
+                    "},
+                    cursor_offset: Some(6),
+                    expected_variable_edit: indoc! {"
+                        one
+                        two
+                        <|fim_middle|>
+                        TH<|user_cursor|>REE
+                        <|fim_suffix|>
+                        four
+                        five
+                    "},
+                    expected_after_apply: indoc! {"
+                        zero
+                        one
+                        two
+                        TH<|user_cursor|>REE
+                        four
+                        five
+                    "},
+                },
+                Case {
+                    name: "expands_context_when_two_lines_not_unique_before_and_after",
+                    old: indoc! {"
+                        one
+                        a
+                        b
+                        c
+                        d
+                        two
+                        a
+                        b
+                        c
+                        d
+                        three
+                        a
+                        b
+                        c
+                        d
+                        four
+                    "},
+                    patch: indoc! {"
+                        @@ -4,5 +4,5 @@
+                         two
+                         a
+                         b
+                        -c
+                        +C
+                         d
+                         three
+                    "},
+                    cursor_offset: None,
+                    expected_variable_edit: indoc! {"
+                        two
+                        a
+                        b
+                        <|fim_middle|>
+                        C
+                        <|fim_suffix|>
+                        d
+                        three
+                    "},
+                    expected_after_apply: indoc! {"
+                        one
+                        a
+                        b
+                        c
+                        d
+                        two
+                        a
+                        b
+                        C
+                        d
+                        three
+                        a
+                        b
+                        c
+                        d
+                        four
+                    "},
+                },
+                Case {
+                    name: "expands_context_when_two_lines_not_unique_before_and_after",
+                    old: indoc! {"
+                        {
+                            {
+                                one();
+                            }
+                        }
+                        {
+                            {
+                                two();
+                            }
+                        }
+                        {
+                            {
+                                three();
+                            }
+                        }
+                        {
+                            {
+                                four();
+                            }
+                        }
+                    "},
+                    patch: indoc! {"
+                        @@ -4,5 +4,5 @@
+                             {
+                        -        two();
+                        +        TWO();
+                             }
+                    "},
+                    cursor_offset: None,
+                    expected_variable_edit: indoc! {"
+                                one();
+                            }
+                        }
+                        {
+                            {
+                        <|fim_middle|>
+                                TWO();
+                        <|fim_suffix|>
+                            }
+                        }
+                        {
+                            {
+                                three();
+                    "},
+                    expected_after_apply: indoc! {"
+                        {
+                            {
+                                one();
+                            }
+                        }
+                        {
+                            {
+                                TWO();
+                            }
+                        }
+                        {
+                            {
+                                three();
+                            }
+                        }
+                        {
+                            {
+                                four();
+                            }
+                        }
+                    "},
+                },
+            ];
+
+            for case in cases {
+                let output =
+                    patch_to_variable_edit_output(case.old, case.patch, case.cursor_offset)
+                        .unwrap_or_else(|error| {
+                            panic!("failed converting patch for {}: {error}", case.name)
+                        });
+                assert_eq!(
+                    output, case.expected_variable_edit,
+                    "patch->variable_edit mismatch for {}",
+                    case.name
+                );
+
+                let (edit_range, replacement) = apply_variable_edit(case.old, &output)
+                    .unwrap_or_else(|error| {
+                        panic!("failed applying variable_edit for {}: {error}", case.name)
+                    });
+                let mut edited_by_variable_edit = case.old.to_string();
+                edited_by_variable_edit.replace_range(edit_range, &replacement);
+                assert_eq!(
+                    edited_by_variable_edit, case.expected_after_apply,
+                    "variable_edit apply mismatch for {}",
+                    case.name
+                );
+
+                let (expected_edit_range, expected_replacement) =
+                    apply_variable_edit(case.old, case.expected_variable_edit).unwrap_or_else(
+                        |error| {
+                            panic!(
+                                "failed applying expected variable_edit for {}: {error}",
+                                case.name
+                            )
+                        },
+                    );
+                let mut edited_by_expected_variable_edit = case.old.to_string();
+                edited_by_expected_variable_edit
+                    .replace_range(expected_edit_range, &expected_replacement);
+                assert_eq!(
+                    edited_by_expected_variable_edit, case.expected_after_apply,
+                    "expected variable_edit apply mismatch for {}",
+                    case.name
+                );
+            }
+        }
+
+        #[test]
+        fn test_write_cursor_excerpt_section() {
+            let path = Path::new("test.rs");
+            let context = "fn main() {\n    hello();\n}\n";
+            let cursor_offset = 17;
+            let mut prompt = String::new();
+            write_cursor_excerpt_section(&mut prompt, path, context, cursor_offset);
+            assert_eq!(
+                prompt,
+                "<|file_sep|>test.rs\nfn main() {\n    h<|user_cursor|>ello();\n}\n<|fim_prefix|>\n"
+            );
+        }
+    }
+}
+
+/// The zeta1 prompt format
+pub mod zeta1 {
+    use super::*;
+    use std::fmt::Write;
+
+    pub const CURSOR_MARKER: &str = "<|user_cursor_is_here|>";
+    pub const START_OF_FILE_MARKER: &str = "<|start_of_file|>";
+    pub const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>";
+    pub const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>";
+
+    const INSTRUCTION_HEADER: &str = concat!(
+        "### Instruction:\n",
+        "You are a code completion assistant and your task is to analyze user edits and then rewrite an ",
+        "excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking ",
+        "into account the cursor location.\n\n",
+        "### User Edits:\n\n"
+    );
+    const EXCERPT_HEADER: &str = "\n\n### User Excerpt:\n\n";
+    const RESPONSE_HEADER: &str = "\n\n### Response:\n";
+
+    /// Formats a complete zeta1 prompt from the input events and excerpt.
+    pub fn format_zeta1_prompt(input_events: &str, input_excerpt: &str) -> String {
+        let mut prompt = String::with_capacity(
+            INSTRUCTION_HEADER.len()
+                + input_events.len()
+                + EXCERPT_HEADER.len()
+                + input_excerpt.len()
+                + RESPONSE_HEADER.len(),
+        );
+        prompt.push_str(INSTRUCTION_HEADER);
+        prompt.push_str(input_events);
+        prompt.push_str(EXCERPT_HEADER);
+        prompt.push_str(input_excerpt);
+        prompt.push_str(RESPONSE_HEADER);
+        prompt
+    }
+
+    /// Formats a complete zeta1 prompt from a `ZetaPromptInput` using the given
+    /// editable and context byte-offset ranges within `cursor_excerpt`.
+    pub fn format_zeta1_from_input(
+        input: &ZetaPromptInput,
+        editable_range: Range<usize>,
+        context_range: Range<usize>,
+    ) -> String {
+        let events = format_zeta1_events(&input.events);
+        let excerpt = format_zeta1_excerpt(input, editable_range, context_range);
+        format_zeta1_prompt(&events, &excerpt)
+    }
+
+    /// Formats events in zeta1 style (oldest first).
     fn format_zeta1_events(events: &[Arc<Event>]) -> String {
         let mut result = String::new();
-        for event in events {
+        for event in
+            events
+                .iter()
+                .skip(events.len().saturating_sub(max_edit_event_count_for_format(
+                    &ZetaFormat::V0114180EditableRegion,
+                )))
+        {
             let event_string = format_zeta1_event(event);
             if event_string.is_empty() {
                 continue;

docs/AGENTS.md 🔗

@@ -126,6 +126,59 @@ Images are hosted externally. Reference format:
 - With anchors: `[Custom Models](./llm-providers.md#anthropic-custom-models)`
 - Parent directory: `[Telemetry](../telemetry.md)`
 
+## Voice and Tone
+
+### Core Principles
+
+- **Practical over promotional**: Focus on what users can do, not on selling Zed. Avoid marketing language like "powerful," "revolutionary," or "best-in-class."
+- **Honest about limitations**: When Zed lacks a feature or doesn't match another tool's depth, say so directly. Pair limitations with workarounds or alternative workflows.
+- **Direct and concise**: Use short sentences. Get to the point. Developers are scanning, not reading novels.
+- **Second person**: Address the reader as "you." Avoid "the user" or "one."
+- **Present tense**: "Zed opens the file" not "Zed will open the file."
+
+### What to Avoid
+
+- Superlatives without substance ("incredibly fast," "seamlessly integrated")
+- Hedging language ("simply," "just," "easily")—if something is simple, the instructions will show it
+- Apologetic tone for missing features—state the limitation and move on
+- Comparisons that disparage other tools—be factual, not competitive
+- Lots of use of em or en dashes.
+
+## Examples of Good Copy
+
+### Good: Direct and actionable
+
+```
+To format on save, open the Settings Editor (`Cmd+,`) and search for `format_on_save`. Set it to `on`.
+
+Or add this to your settings.json:
+{
+  "format_on_save": "on"
+}
+```
+
+### Bad: Wordy and promotional
+
+```
+Zed provides a powerful and seamless formatting experience. Simply navigate to the settings and you'll find the format_on_save option which enables Zed's incredible auto-formatting capabilities.
+```
+
+### Good: Honest about limitations
+
+```
+Zed doesn't index your project like IntelliJ does. You open a folder and start working immediately—no waiting. The trade-off: cross-project analysis relies on language servers, which may not go as deep.
+
+**How to adapt:**
+- Use `Cmd+Shift+F` for project-wide text search
+- Use `Cmd+O` for symbol search (powered by your language server)
+```
+
+### Bad: Defensive or dismissive
+
+```
+While some users might miss indexing, Zed's approach is actually better because it's faster.
+```
+
 ## Scope
 
 ### In-Scope Documentation
@@ -204,13 +257,14 @@ Inherit all conventions from `docs/.rules`. Key points:
 
 ### Terminology
 
-| Use             | Instead of                             |
-| --------------- | -------------------------------------- |
-| folder          | directory                              |
-| project         | workspace                              |
-| Settings Editor | settings UI                            |
-| command palette | command bar                            |
-| panel           | sidebar (be specific: "Project Panel") |
+| Use             | Instead of                                                            |
+| --------------- | --------------------------------------------------------------------- |
+| folder          | directory                                                             |
+| project         | workspace                                                             |
+| Settings Editor | settings UI                                                           |
+| command palette | command bar                                                           |
+| panel           | tool window, sidebar (be specific: "Project Panel," "Terminal Panel") |
+| language server | LSP (spell out first use, then LSP is fine)                           |
 
 ## Zed-Specific Conventions
 

docs/src/SUMMARY.md 🔗

@@ -161,6 +161,7 @@
 - [Debugger Extensions](./extensions/debugger-extensions.md)
 - [Theme Extensions](./extensions/themes.md)
 - [Icon Theme Extensions](./extensions/icon-themes.md)
+- [Snippets Extensions](./extensions/snippets.md)
 - [Slash Command Extensions](./extensions/slash-commands.md)
 - [Agent Server Extensions](./extensions/agent-servers.md)
 - [MCP Server Extensions](./extensions/mcp-extensions.md)

docs/src/ai/agent-settings.md 🔗

@@ -1,6 +1,6 @@
 ---
 title: AI Agent Settings - Zed
-description: Customize Zed's AI agent: default models, temperature, tool approval, auto-run commands, notifications, and panel options.
+description: "Customize Zed's AI agent: default models, temperature, tool approval, auto-run commands, notifications, and panel options."
 ---
 
 # Agent Settings

docs/src/ai/privacy-and-security.md 🔗

@@ -1,6 +1,6 @@
 ---
 title: AI Privacy and Security - Zed
-description: Zed's approach to AI privacy: opt-in data sharing by default, zero-data retention with providers, and full open-source transparency.
+description: "Zed's approach to AI privacy: opt-in data sharing by default, zero-data retention with providers, and full open-source transparency."
 ---
 
 # Privacy and Security

docs/src/appearance.md 🔗

@@ -15,11 +15,13 @@ Here's how to make Zed feel like home:
 
 1. **Pick a theme**: Press {#kb theme_selector::Toggle} to open the Theme Selector. Arrow through the list to preview themes in real time, and press Enter to apply.
 
-2. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes.
+2. **Toggle light/dark mode quickly**: Press {#kb theme::ToggleMode}. If you currently use a static `"theme": "..."` value, the first toggle converts it to dynamic mode settings with default themes.
 
-3. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font.
+3. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes.
 
-4. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes.
+4. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font.
+
+5. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes.
 
 That's it. You now have a personalized Zed setup.
 

docs/src/development/feature-process.md 🔗

@@ -0,0 +1,55 @@
+# Zed's Feature Development Process
+
+This is for moderate-to-large features — new UI, behavior changes, or work that cuts across multiple parts of Zed. Small keybindings or settings tweaks don't need all of this.
+
+> **Before you start:** If you're an external contributor, make sure the feature is something the team wants before investing significant effort. Please read the [Contributing Guide](../../../CONTRIBUTING.md) and our [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/51422) — if there isn't already a GitHub issue with clear staff confirmation, start with a GitHub Discussion. Feature request PRs that skip this process have a _very_ low merge rate. Taking the time to follow our process significantly increases the chances your idea gets picked up and built.
+
+## 1. Why does this matter?
+
+Every feature starts as an idea. Before writing any code, ground it:
+
+- **What problem does this solve?**
+- **What's the evidence?** GitHub issues, Discord requests, thumbs-up counts, blog posts.
+- **Is there prior art?** If it's in VS Code, JetBrains, Neovim, or a wildly popular plugin, that's a strong signal. If the idea is more novel, name what it's based on — "This is X, adapted for Zed's multi-buffers" is far more useful than "I think this would be cool."
+
+## 2. What is it?
+
+Write a short, concrete feature statement, then back it up with the context gathered above. If you can't describe the feature in a few sentences, it might be too big or too vague.
+
+Here's an example format, though adapt it to whatever your feature needs:
+
+**Feature:** Inline Git Blame
+
+**Purpose:** Show the last commit author and message for each line directly after the editor text, so developers can understand code history without opening the git blame.
+
+**Background:**
+This is standard across all major code editors:
+
+- \[screenshot of VSCode]
+- \[screenshot of Intellij]
+- \[screenshot of Neovim]
+- and has 146 thumbs up on this [github issue](https://github.com).
+
+**Decisions:**
+We have to decide whether to use the git CLI or a git library. Zed uses a git library but its blame implementation is too slow for a code editor, so we should use the CLI's porcelain interface.
+
+## 3. What else does this affect?
+
+Walk through this list before you start building. Not everything will apply:
+
+- **Actions & keybindings.** What actions does your feature define? Do the default keybindings conflict with existing ones?
+- **Settings.** Is any behavior configurable? Per-user vs. per-project vs. per-language? Don't forget to add new settings to the Settings UI.
+- **Themes & styling.** Does this need a new semantic token? Does it look right in both light and dark mode?
+- **Vim mode.** Vim users might have different expectations for this feature.
+- **Remote development.** Does your feature work with remote projects? File paths, shell commands, and environment variables all might behave differently.
+- **Persistence across restarts.** Should your feature's state persist across restarts?
+- **Accessibility.** Is it keyboard-navigable? Are focus states clear?
+- **Platform differences.** Does behavior differ on macOS, Linux, or Windows?
+- **Performance.** How does it behave with large files or big projects? Are interactions instant?
+- **Security.** How does this feature interact with Workspace Trust? Does it open new attack surfaces in Zed?
+
+If your feature touches the **editor** specifically: the editor has a lot of coexisting features — gutter elements, inline blocks, multiple cursors, folding, edit predictions, code intelligence popovers, the minimap. Test your changes with different combinations of them active. Features that work in a normal buffer might need to be disabled in a multi-buffer.
+
+## 4. Ship it
+
+Use this as the basis for your GitHub Discussion, issue, or PR description. Good product research gets everyone aligned on goals, the state of the art, and any tradeoffs we might need to consider.

docs/src/development/glossary.md 🔗

@@ -1,5 +1,5 @@
 ---
-title: Zed Development: Glossary
+title: "Zed Development: Glossary"
 description: "Guide to zed development: glossary for Zed development."
 ---
 

docs/src/development/macos.md 🔗

@@ -89,7 +89,7 @@ Before making any UI changes, generate baseline images from a known-good state:
 
 ```sh
 git checkout origin/main
-UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests
+UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visual-tests
 git checkout -
 ```
 

docs/src/extensions.md 🔗

@@ -14,6 +14,7 @@ Zed lets you add new functionality using user-defined extensions.
   - [Developing Debugger Extensions](./extensions/debugger-extensions.md)
   - [Developing Themes](./extensions/themes.md)
   - [Developing Icon Themes](./extensions/icon-themes.md)
+  - [Developing Snippets](./extensions/snippets.md)
   - [Developing Slash Commands](./extensions/slash-commands.md)
   - [Developing Agent Servers](./extensions/agent-servers.md)
   - [Developing MCP Servers](./extensions/mcp-extensions.md)

docs/src/extensions/developing-extensions.md 🔗

@@ -5,7 +5,7 @@ description: "Create Zed extensions: languages, themes, debuggers, slash command
 
 # Developing Extensions {#developing-extensions}
 
-Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, slash commands, and MCP servers.
+Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, snippets, slash commands, and MCP servers.
 
 ## Extension Features {#extension-features}
 
@@ -15,6 +15,7 @@ Extensions can provide:
 - [Debuggers](./debugger-extensions.md)
 - [Themes](./themes.md)
 - [Icon Themes](./icon-themes.md)
+- [Snippets](./snippets.md)
 - [Slash Commands](./slash-commands.md)
 - [MCP Servers](./mcp-extensions.md)
 
@@ -63,6 +64,9 @@ my-extension/
       highlights.scm
   themes/
     my-theme.json
+  snippets/
+    snippets.json
+    rust.json
 ```
 
 ## WebAssembly
@@ -126,9 +130,11 @@ The following licenses are accepted:
 - [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0)
 - [BSD 2-Clause](https://opensource.org/license/bsd-2-clause)
 - [BSD 3-Clause](https://opensource.org/license/bsd-3-clause)
+- [CC BY 4.0](https://creativecommons.org/licenses/by/4.0)
 - [GNU GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html)
 - [GNU LGPLv3](https://www.gnu.org/licenses/lgpl-3.0.en.html)
 - [MIT](https://opensource.org/license/mit)
+- [Unlicense](https://unlicense.org)
 - [zlib](https://opensource.org/license/zlib)
 
 This allows us to distribute the resulting binary produced from your extension code to our users.

docs/src/extensions/languages.md 🔗

@@ -52,7 +52,7 @@ TBD: Document `language_name/config.toml` keys
 
 ## Grammar
 
-Zed uses the [Tree-sitter](https://tree-sitter.github.io) parsing library to provide built-in language-specific features. There are grammars available for many languages, and you can also [develop your own grammar](https://tree-sitter.github.io/tree-sitter/creating-parsers#writing-the-grammar). A growing list of Zed features are built using pattern matching over syntax trees with Tree-sitter queries. As mentioned above, every language that is defined in an extension must specify the name of a Tree-sitter grammar that is used for parsing. These grammars are then registered separately in extensions' `extension.toml` file, like this:
+Zed uses the [Tree-sitter](https://tree-sitter.github.io) parsing library to provide built-in language-specific features. There are grammars available for many languages, and you can also [develop your own grammar](https://tree-sitter.github.io/tree-sitter/creating-parsers/3-writing-the-grammar.html). A growing list of Zed features are built using pattern matching over syntax trees with Tree-sitter queries. As mentioned above, every language that is defined in an extension must specify the name of a Tree-sitter grammar that is used for parsing. These grammars are then registered separately in extensions' `extension.toml` file, like this:
 
 ```toml
 [grammars.gleam]

docs/src/extensions/snippets.md 🔗

@@ -0,0 +1,27 @@
+---
+title: Snippets
+description: "Snippets for Zed extensions."
+---
+
+# Snippets
+
+Extensions may provide snippets for one or more languages.
+
+Each file containing snippets can be specified in the `snippets` field of the `extensions.toml` file.
+
+The referenced path must be relative to the `extension.toml`.
+
+## Defining Snippets
+
+A given extension may provide one or more snippets. Each snippet must be registered in the `extension.toml`.
+
+Zed matches snippet files based on the lowercase name of the language (e.g. `rust.json` for Rust).
+You can use `snippets.json` as a file name to define snippets that will be available regardless of the current buffer language.
+
+For example, here is an extension that provides snippets for Rust and TypeScript:
+
+```toml
+snippets = ["./snippets/rust.json", "./snippets/typescript.json"]
+```
+
+For more information on how to create snippets, see the [Snippets documentation](../snippets.md).

docs/src/languages/python.md 🔗

@@ -89,7 +89,7 @@ Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages
   "languages": {
     "Python": {
       "language_servers": [
-        // Disable basedpyright and enable ty, and include all
+        // Enable ty, disable basedpyright, and enable all
         // other registered language servers (ruff, pylsp, pyright).
         "ty",
         "!basedpyright",

docs/src/languages/vue.md 🔗

@@ -8,7 +8,59 @@ description: "Configure Vue language support in Zed, including language servers,
 Vue support is available through the [Vue extension](https://github.com/zed-extensions/vue).
 
 - Tree-sitter: [tree-sitter-grammars/tree-sitter-vue](https://github.com/tree-sitter-grammars/tree-sitter-vue)
-- Language Server: [vuejs/language-tools/](https://github.com/vuejs/language-tools/)
+- Language Server: [vuejs/language-tools](https://github.com/vuejs/language-tools)
+
+## Initialization Options
+
+### Specifying location of TypeScript SDK
+
+By default, this extension assumes that you are working in a project with a `node_modules` directory, and searches for
+the TypeScript SDK inside that directory.
+
+This may not always be true; for example, when working in a project that uses Yarn PnP, there is no `node_modules`. For
+editor support, the [documented](https://yarnpkg.com/getting-started/editor-sdks) approach is to run something like
+`yarn dlx @yarnpkg/sdks`. In that case, you can provide the following initialization options in your Zed settings:
+
+```json
+{
+  "lsp": {
+    "vue": {
+      "initialization_options": {
+        "typescript": {
+          "tsdk": ".yarn/sdks/typescript/lib"
+        }
+      }
+    }
+  }
+}
+```
+
+## Settings Options
+
+`lsp.vue.settings` is passed through to the Vue language server (Volar / [`vuejs/language-tools`](https://github.com/vuejs/language-tools)). The following settings are enabled by default:
+
+```json
+{
+  "lsp": {
+    "vue": {
+      "settings": {
+        // Display inlay hints for the `$event` parameter in inline event handlers.
+        "vue.inlayHints.inlineHandlerLeading": true,
+        // Display hints when required component props are missing in templates.
+        "vue.inlayHints.missingProps": true,
+        // Display inlay hints for patterns that wrap component options.
+        "vue.inlayHints.optionsWrapper": true,
+        // Display inlay hints related to `v-bind` shorthand (`:`).
+        "vue.inlayHints.vBindShorthand": true
+      }
+    }
+  }
+}
+```
+
+You can find the upstream settings configuration schema [`here`](https://github.com/vuejs/language-tools/blob/ee5041d27940cf6f9a5150635d3b13140a9dff54/extensions/vscode/package.json#L252).
+
+> Note: Some settings (e.g. `vue.editor.focusMode`) may not take effect.
 
 ## Using the Tailwind CSS Language Server with Vue
 

docs/src/reference/all-settings.md 🔗

@@ -4695,7 +4695,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
     "bold_folder_labels": false,
     "drag_and_drop": true,
     "scrollbar": {
-      "show": null
+      "show": null,
+      "horizontal_scroll": true
     },
     "sticky_scroll": true,
     "show_diagnostics": "all",
@@ -4941,9 +4942,9 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 }
 ```
 
-### Scrollbar: Show
+### Scrollbar
 
-- Description: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details.
+- Description: Scrollbar-related settings for the project panel.
 - Setting: `scrollbar`
 - Default:
 
@@ -4951,7 +4952,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 {
   "project_panel": {
     "scrollbar": {
-      "show": null
+      "show": null,
+      "horizontal_scroll": true
     }
   }
 }
@@ -4959,29 +4961,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 
 **Options**
 
-1. Show scrollbar in the project panel
-
-```json [settings]
-{
-  "project_panel": {
-    "scrollbar": {
-      "show": "always"
-    }
-  }
-}
-```
-
-2. Hide scrollbar in the project panel
-
-```json [settings]
-{
-  "project_panel": {
-    "scrollbar": {
-      "show": "never"
-    }
-  }
-}
-```
+- `show`: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details.
+- `horizontal_scroll`: Whether to allow horizontal scrolling in the project panel. When `false`, the view is locked to the leftmost position and long file names are clipped.
 
 ### Sort Mode
 

docs/src/themes.md 🔗

@@ -44,6 +44,35 @@ You can set the mode to `"dark"` or `"light"` to ignore the current system mode.
 }
 ```
 
+### Toggle Theme Mode from the Keyboard
+
+Use {#kb theme::ToggleMode} to switch the current theme mode between light and dark.
+
+If your settings currently use a static theme value, like:
+
+```json [settings]
+{
+  "theme": "Any Theme"
+}
+```
+
+the first toggle converts it to dynamic theme selection with default themes:
+
+```json [settings]
+{
+  "theme": {
+    "mode": "system",
+    "light": "One Light",
+    "dark": "One Dark"
+  }
+}
+```
+
+You are required to set both `light` and `dark` themes manually after the first toggle.
+
+After that, toggling updates only `theme.mode`.
+If `light` and `dark` are the same theme, the first toggle may not produce a visible UI change until you set different values for `light` and `dark`.
+
 ## Theme Overrides
 
 To override specific attributes of a theme, use the `theme_overrides` setting.

docs/theme/css/chrome.css 🔗

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

docs/theme/index.hbs 🔗

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

extensions/glsl/languages/glsl/config.toml 🔗

@@ -5,6 +5,8 @@ path_suffixes = [
     "vert", "frag", "tesc", "tese", "geom",
     # Compute shaders
     "comp",
+    # Mesh pipeline shaders
+    "task", "mesh",
     # Ray tracing pipeline shaders
     "rgen", "rint", "rahit", "rchit", "rmiss", "rcall",
     # Other

nix/build.nix 🔗

@@ -52,6 +52,7 @@
 
   withGLES ? false,
   profile ? "release",
+  commitSha ? null,
 }:
 assert withGLES -> stdenv.hostPlatform.isLinux;
 let
@@ -84,7 +85,10 @@ let
     in
     rec {
       pname = "zed-editor";
-      version = zedCargoLock.package.version + "-nightly";
+      version =
+        zedCargoLock.package.version
+        + "-nightly"
+        + lib.optionalString (commitSha != null) "+${builtins.substring 0 7 commitSha}";
       src = builtins.path {
         path = ../.;
         filter = mkIncludeFilter ../.;
@@ -220,6 +224,7 @@ let
         };
         ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled.";
         RELEASE_VERSION = version;
+        ZED_COMMIT_SHA = lib.optionalString (commitSha != null) "${commitSha}";
         LK_CUSTOM_WEBRTC = pkgs.callPackage ./livekit-libwebrtc/package.nix { };
         PROTOC = "${protobuf}/bin/protoc";
 

nix/modules/devshells.nix 🔗

@@ -22,10 +22,14 @@
       # Cargo build timings wrapper script
       wrappedCargo = pkgs.writeShellApplication {
         name = "cargo";
-        runtimeInputs = [pkgs.nodejs];
-        text = ''
-          NIX_WRAPPER=1 CARGO=${rustToolchain}/bin/cargo ./script/cargo "$@"
-        '';
+        runtimeInputs = [ pkgs.nodejs ];
+        text =
+          let
+            pathToCargoScript = ./. + "/../../script/cargo";
+          in
+          ''
+            NIX_WRAPPER=1 CARGO=${rustToolchain}/bin/cargo ${pathToCargoScript} "$@"
+          '';
       };
     in
     {
@@ -34,7 +38,7 @@
         inputsFrom = [ zed-editor ];
 
         packages = with pkgs; [
-          wrappedCargo  # must be first, to shadow the `cargo` provided by `rustToolchain`
+          wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain`
           rustToolchain # cargo, rustc, and rust-toolchain.toml components included
           cargo-nextest
           cargo-hakari

nix/toolchain.nix 🔗

@@ -6,4 +6,5 @@ in
 pkgs.callPackage ./build.nix {
   crane = inputs.crane.mkLib pkgs;
   rustToolchain = rustBin.fromRustupToolchainFile ../rust-toolchain.toml;
+  commitSha = inputs.self.rev or null;
 }

script/danger/dangerfile.ts 🔗

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

script/linux 🔗

@@ -60,12 +60,21 @@ if [[ -n $apt ]]; then
     # Ubuntu 20.04 ships clang-10 and libstdc++-10 which lack adequate C++20
     # support for building webrtc-sys (requires -std=c++20, lambdas in
     # unevaluated contexts from clang 17+, and working std::ranges in the
-    # stdlib). clang-18 is available in focal-security/universe as an official
-    # backport, and libstdc++-11-dev from the ubuntu-toolchain-r PPA provides
-    # headers with working pointer_traits/contiguous_range.
+    # stdlib).
     # Note: the prebuilt libwebrtc.a is compiled with libstdc++, so we must
     # use libstdc++ (not libc++) to avoid ABI mismatches at link time.
-    $maysudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
+
+    # libstdc++-11-dev (headers with working pointer_traits/contiguous_range)
+    # is only available from the ubuntu-toolchain-r PPA. Add the source list
+    # and GPG key manually instead of using add-apt-repository, whose HKP
+    # keyserver lookups (port 11371) frequently time out in CI.
+    $maysudo "$apt" install -y curl gnupg
+    codename=$(lsb_release -cs)
+    echo "deb https://ppa.launchpadcontent.net/ubuntu-toolchain-r/test/ubuntu $codename main" | \
+      $maysudo tee /etc/apt/sources.list.d/ubuntu-toolchain-r-test.list > /dev/null
+    curl -fsSL 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x1E9377A2BA9EF27F' | \
+      sed -n '/-----BEGIN PGP PUBLIC KEY BLOCK-----/,/-----END PGP PUBLIC KEY BLOCK-----/p' | \
+      $maysudo gpg --dearmor -o /etc/apt/trusted.gpg.d/ubuntu-toolchain-r-test.gpg
     deps+=( clang-18 libstdc++-11-dev )
   fi
 

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

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

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

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

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

@@ -0,0 +1,113 @@
+use gh_workflow::{
+    Event, Expression, Input, Job, Level, Permissions, Push, Strategy, UsesJob, Workflow,
+};
+use indoc::indoc;
+use serde_json::json;
+
+use crate::tasks::workflows::{
+    extensions::WithAppSecrets,
+    run_tests::DETECT_CHANGED_EXTENSIONS_SCRIPT,
+    runners,
+    steps::{self, CommonJobConditions, NamedJob, named},
+    vars::{StepOutput, one_workflow_per_non_main_branch},
+};
+
+/// Generates a workflow that triggers on push to main, detects changed extensions
+/// in the `extensions/` directory, and invokes the `extension_bump` reusable workflow
+/// for each changed extension via a matrix strategy.
+pub(crate) fn extension_auto_bump() -> Workflow {
+    let detect = detect_changed_extensions();
+    let bump = bump_extension_versions(&detect);
+
+    named::workflow()
+        .add_event(
+            Event::default().push(
+                Push::default()
+                    .add_branch("main")
+                    .add_path("extensions/**")
+                    .add_path("!extensions/workflows/**")
+                    .add_path("!extensions/*.md"),
+            ),
+        )
+        .concurrency(one_workflow_per_non_main_branch())
+        .add_job(detect.name, detect.job)
+        .add_job(bump.name, bump.job)
+}
+
+fn detect_changed_extensions() -> NamedJob {
+    let preamble = indoc! {r#"
+        COMPARE_REV="$(git rev-parse HEAD~1)"
+        CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
+    "#};
+
+    let filter_new_and_removed = indoc! {r#"
+        # Filter out newly added or entirely removed extensions
+        FILTERED="[]"
+        for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do
+            if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \
+               [ -f "$ext/extension.toml" ]; then
+                FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]')
+            fi
+        done
+        echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT"
+    "#};
+
+    let script = format!(
+        "{preamble}{detect}{filter}",
+        preamble = preamble,
+        detect = DETECT_CHANGED_EXTENSIONS_SCRIPT,
+        filter = filter_new_and_removed,
+    );
+
+    let step = named::bash(script).id("detect");
+
+    let output = StepOutput::new(&step, "changed_extensions");
+
+    let job = Job::default()
+        .with_repository_owner_guard()
+        .runs_on(runners::LINUX_SMALL)
+        .timeout_minutes(5u32)
+        .add_step(steps::checkout_repo().with_custom_fetch_depth(2))
+        .add_step(step)
+        .outputs([("changed_extensions".to_owned(), output.to_string())]);
+
+    named::job(job)
+}
+
+fn bump_extension_versions(detect_job: &NamedJob) -> NamedJob<UsesJob> {
+    let job = Job::default()
+        .needs(vec![detect_job.name.clone()])
+        .cond(Expression::new(format!(
+            "needs.{}.outputs.changed_extensions != '[]'",
+            detect_job.name
+        )))
+        .permissions(
+            Permissions::default()
+                .contents(Level::Write)
+                .issues(Level::Write)
+                .pull_requests(Level::Write)
+                .actions(Level::Write),
+        )
+        .strategy(
+            Strategy::default()
+                .fail_fast(false)
+                // TODO: Remove the limit. We currently need this to workaround the concurrency group issue
+                // where different matrix jobs would be placed in the same concurrency group and thus cancelled.
+                .max_parallel(1u32)
+                .matrix(json!({
+                    "extension": format!(
+                        "${{{{ fromJson(needs.{}.outputs.changed_extensions) }}}}",
+                        detect_job.name
+                    )
+                })),
+        )
+        .uses_local(".github/workflows/extension_bump.yml")
+        .with(
+            Input::default()
+                .add("working-directory", "${{ matrix.extension }}")
+                .add("force-bump", false),
+        )
+        .with_app_secrets();
+
+    named::job(job)
+}

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

@@ -5,11 +5,12 @@ use crate::tasks::workflows::{
     extension_tests::{self},
     runners,
     steps::{
-        self, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, NamedJob,
-        checkout_repo, dependant_job, named,
+        self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder,
+        NamedJob, checkout_repo, dependant_job, named,
     },
     vars::{
-        JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch,
+        JobOutput, StepOutput, WorkflowInput, WorkflowSecret,
+        one_workflow_per_non_main_branch_and_token,
     },
 };
 
@@ -22,6 +23,7 @@ pub(crate) fn extension_bump() -> Workflow {
     // TODO: Ideally, this would have a default of `false`, but this is currently not
     // supported in gh-workflows
     let force_bump = WorkflowInput::bool("force-bump", None);
+    let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
 
     let (app_id, app_secret) = extension_workflow_secrets();
     let (check_version_changed, version_changed, current_version) = check_version_changed();
@@ -59,6 +61,7 @@ pub(crate) fn extension_bump() -> Workflow {
                 WorkflowCall::default()
                     .add_input(bump_type.name, bump_type.call_input())
                     .add_input(force_bump.name, force_bump.call_input())
+                    .add_input(working_directory.name, working_directory.call_input())
                     .secrets([
                         (app_id.name.to_owned(), app_id.secret_configuration()),
                         (
@@ -68,7 +71,7 @@ pub(crate) fn extension_bump() -> Workflow {
                     ]),
             ),
         )
-        .concurrency(one_workflow_per_non_main_branch())
+        .concurrency(one_workflow_per_non_main_branch_and_token("extension-bump"))
         .add_env(("CARGO_TERM_COLOR", "always"))
         .add_env(("RUST_BACKTRACE", 1))
         .add_env(("CARGO_INCREMENTAL", 0))
@@ -82,10 +85,19 @@ pub(crate) fn extension_bump() -> Workflow {
         .add_job(trigger_release.name, trigger_release.job)
 }
 
+fn extension_job_defaults() -> Defaults {
+    Defaults::default().run(
+        RunDefaults::default()
+            .shell(BASH_SHELL)
+            .working_directory("${{ inputs.working-directory }}"),
+    )
+}
+
 fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) {
     let (compare_versions, version_changed, current_version) = compare_versions();
 
     let job = Job::default()
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .outputs([
             (version_changed.name.to_owned(), version_changed.to_string()),
@@ -112,6 +124,7 @@ fn create_version_label(
     let (generate_token, generated_token) =
         generate_token(&app_id.to_string(), &app_secret.to_string(), None);
     let job = steps::dependant_job(dependencies)
+        .defaults(extension_job_defaults())
         .cond(Expression::new(format!(
             "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \
             github.ref == 'refs/heads/main' && {version_changed} == 'true'",
@@ -153,8 +166,6 @@ pub(crate) fn compare_versions() -> (Step<Run>, StepOutput, StepOutput) {
         if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
             PR_FORK_POINT="$(git merge-base origin/main HEAD)"
             git checkout "$PR_FORK_POINT"
-        elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
-            git checkout "$BRANCH_PARENT_SHA"
         else
             git checkout "$(git log -1 --format=%H)"~1
         fi
@@ -187,9 +198,11 @@ fn bump_extension_version(
 ) -> NamedJob {
     let (generate_token, generated_token) =
         generate_token(&app_id.to_string(), &app_secret.to_string(), None);
-    let (bump_version, new_version) = bump_version(current_version, bump_type);
+    let (bump_version, _new_version, title, body, branch_name) =
+        bump_version(current_version, bump_type);
 
     let job = steps::dependant_job(dependencies)
+        .defaults(extension_job_defaults())
         .cond(Expression::new(format!(
             "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')",
             force_bump = force_bump_output.expr(),
@@ -201,7 +214,12 @@ fn bump_extension_version(
         .add_step(steps::checkout_repo())
         .add_step(install_bump_2_version())
         .add_step(bump_version)
-        .add_step(create_pull_request(new_version, generated_token));
+        .add_step(create_pull_request(
+            title,
+            body,
+            generated_token,
+            branch_name,
+        ));
 
     named::job(job)
 }
@@ -256,7 +274,10 @@ fn install_bump_2_version() -> Step<Run> {
     )
 }
 
-fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step<Run>, StepOutput) {
+fn bump_version(
+    current_version: &JobOutput,
+    bump_type: &WorkflowInput,
+) -> (Step<Run>, StepOutput, StepOutput, StepOutput, StepOutput) {
     let step = named::bash(formatdoc! {r#"
         BUMP_FILES=("extension.toml")
         if [[ -f "Cargo.toml" ]]; then
@@ -274,33 +295,50 @@ fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step
         fi
 
         NEW_VERSION="$({VERSION_CHECK})"
+        EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+        EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+
+        if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
+            {{
+                echo "title=Bump version to ${{NEW_VERSION}}";
+                echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}";
+                echo "branch_name=zed-zippy-autobump";
+            }} >> "$GITHUB_OUTPUT"
+        else
+            {{
+                echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}";
+                echo "body=This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}";
+                echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump";
+            }} >> "$GITHUB_OUTPUT"
+        fi
 
         echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT"
         "#
     })
     .id("bump-version")
     .add_env(("OLD_VERSION", current_version.to_string()))
-    .add_env(("BUMP_TYPE", bump_type.to_string()));
+    .add_env(("BUMP_TYPE", bump_type.to_string()))
+    .add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
 
     let new_version = StepOutput::new(&step, "new_version");
-    (step, new_version)
+    let title = StepOutput::new(&step, "title");
+    let body = StepOutput::new(&step, "body");
+    let branch_name = StepOutput::new(&step, "branch_name");
+    (step, new_version, title, body, branch_name)
 }
 
-fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> Step<Use> {
-    let formatted_version = format!("v{new_version}");
-
+fn create_pull_request(
+    title: StepOutput,
+    body: StepOutput,
+    generated_token: StepOutput,
+    branch_name: StepOutput,
+) -> Step<Use> {
     named::uses("peter-evans", "create-pull-request", "v7").with(
         Input::default()
-            .add("title", format!("Bump version to {new_version}"))
-            .add(
-                "body",
-                format!("This PR bumps the version of this extension to {formatted_version}",),
-            )
-            .add(
-                "commit-message",
-                format!("Bump version to {formatted_version}"),
-            )
-            .add("branch", "zed-zippy-autobump")
+            .add("title", title.to_string())
+            .add("body", body.to_string())
+            .add("commit-message", title.to_string())
+            .add("branch", branch_name.to_string())
             .add(
                 "committer",
                 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
@@ -328,6 +366,7 @@ fn trigger_release(
     let (get_extension_id, extension_id) = get_extension_id();
 
     let job = dependant_job(dependencies)
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .runs_on(runners::LINUX_SMALL)
         .add_step(generate_token)

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

@@ -3,15 +3,13 @@ use indoc::indoc;
 
 use crate::tasks::workflows::{
     extension_bump::compare_versions,
-    run_tests::{
-        fetch_ts_query_ls, orchestrate_without_package_filter, run_ts_query_ls, tests_pass,
-    },
+    run_tests::{fetch_ts_query_ls, orchestrate_for_extension, run_ts_query_ls, tests_pass},
     runners,
     steps::{
-        self, CommonJobConditions, FluentBuilder, NamedJob, cache_rust_dependencies_namespace,
-        named,
+        self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob,
+        cache_rust_dependencies_namespace, named,
     },
-    vars::{PathCondition, StepOutput, one_workflow_per_non_main_branch},
+    vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token},
 };
 
 pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667";
@@ -25,8 +23,10 @@ pub(crate) fn extension_tests() -> Workflow {
     let should_check_extension =
         PathCondition::new("check_extension", r"^(extension\.toml|.*\.scm)$");
 
-    let orchestrate =
-        orchestrate_without_package_filter(&[&should_check_rust, &should_check_extension]);
+    let orchestrate = with_extension_defaults(orchestrate_for_extension(&[
+        &should_check_rust,
+        &should_check_extension,
+    ]));
 
     let jobs = [
         orchestrate,
@@ -34,11 +34,20 @@ pub(crate) fn extension_tests() -> Workflow {
         should_check_extension.guard(check_extension()),
     ];
 
-    let tests_pass = tests_pass(&jobs);
+    let tests_pass = tests_pass(&jobs, &[]);
+
+    let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
 
     named::workflow()
-        .add_event(Event::default().workflow_call(WorkflowCall::default()))
-        .concurrency(one_workflow_per_non_main_branch())
+        .add_event(
+            Event::default().workflow_call(
+                WorkflowCall::default()
+                    .add_input(working_directory.name, working_directory.call_input()),
+            ),
+        )
+        .concurrency(one_workflow_per_non_main_branch_and_token(
+            "extension-tests",
+        ))
         .add_env(("CARGO_TERM_COLOR", "always"))
         .add_env(("RUST_BACKTRACE", 1))
         .add_env(("CARGO_INCREMENTAL", 0))
@@ -58,27 +67,66 @@ fn install_rust_target() -> Step<Run> {
     named::bash(format!("rustup target add {EXTENSION_RUST_TARGET}",))
 }
 
-fn run_clippy() -> Step<Run> {
-    named::bash("cargo clippy --release --all-features -- --deny warnings")
+fn get_package_name() -> (Step<Run>, StepOutput) {
+    let step = named::bash(indoc! {r#"
+        PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')"
+        echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
+    "#})
+    .id("get-package-name");
+
+    let output = StepOutput::new(&step, "package_name");
+    (step, output)
+}
+
+fn cargo_fmt_package(package_name: &StepOutput) -> Step<Run> {
+    named::bash(r#"cargo fmt -p "$PACKAGE_NAME" -- --check"#)
+        .add_env(("PACKAGE_NAME", package_name.to_string()))
+}
+
+fn run_clippy(package_name: &StepOutput) -> Step<Run> {
+    named::bash(r#"cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings"#)
+        .add_env(("PACKAGE_NAME", package_name.to_string()))
+}
+
+fn run_nextest(package_name: &StepOutput) -> Step<Run> {
+    named::bash(
+        r#"cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n 's|host: ||p')""#,
+    )
+    .add_env(("PACKAGE_NAME", package_name.to_string()))
+    .add_env(("NEXTEST_NO_TESTS", "warn"))
+}
+
+fn extension_job_defaults() -> Defaults {
+    Defaults::default().run(
+        RunDefaults::default()
+            .shell(BASH_SHELL)
+            .working_directory("${{ inputs.working-directory }}"),
+    )
+}
+
+fn with_extension_defaults(named_job: NamedJob) -> NamedJob {
+    NamedJob {
+        name: named_job.name,
+        job: named_job.job.defaults(extension_job_defaults()),
+    }
 }
 
 fn check_rust() -> NamedJob {
+    let (get_package, package_name) = get_package_name();
+
     let job = Job::default()
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .runs_on(runners::LINUX_LARGE_RAM)
         .timeout_minutes(6u32)
         .add_step(steps::checkout_repo())
         .add_step(steps::cache_rust_dependencies_namespace())
         .add_step(install_rust_target())
-        .add_step(steps::cargo_fmt())
-        .add_step(run_clippy())
+        .add_step(get_package)
+        .add_step(cargo_fmt_package(&package_name))
+        .add_step(run_clippy(&package_name))
         .add_step(steps::cargo_install_nextest())
-        .add_step(
-            steps::cargo_nextest(runners::Platform::Linux)
-                // Set the target to the current platform again
-                .with_target("$(rustc -vV | sed -n 's|host: ||p')")
-                .add_env(("NEXTEST_NO_TESTS", "warn")),
-        );
+        .add_step(run_nextest(&package_name));
 
     named::job(job)
 }
@@ -88,6 +136,7 @@ pub(crate) fn check_extension() -> NamedJob {
     let (check_version_job, version_changed, _) = compare_versions();
 
     let job = Job::default()
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .runs_on(runners::LINUX_LARGE_RAM)
         .timeout_minutes(6u32)
@@ -124,8 +173,8 @@ pub fn download_zed_extension_cli(cache_hit: StepOutput) -> Step<Run> {
     named::bash(
     indoc! {
         r#"
-        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension"
-        chmod +x zed-extension
+        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension"
+        chmod +x "$GITHUB_WORKSPACE/zed-extension"
         "#,
     }
     ).if_condition(Expression::new(format!("{} != 'true'", cache_hit.expr())))
@@ -136,7 +185,7 @@ pub fn check() -> Step<Run> {
         r#"
         mkdir -p /tmp/ext-scratch
         mkdir -p /tmp/ext-output
-        ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
+        "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
         "#
     })
 }

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

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

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

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

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

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

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

@@ -1,9 +1,10 @@
 use gh_workflow::{
-    Concurrency, Container, Event, Expression, Job, Port, PullRequest, Push, Run, Step, Use,
-    Workflow,
+    Concurrency, Container, Event, Expression, Input, Job, Level, Permissions, Port, PullRequest,
+    Push, Run, Step, Strategy, Use, UsesJob, Workflow,
 };
 use indexmap::IndexMap;
 use indoc::formatdoc;
+use serde_json::json;
 
 use crate::tasks::workflows::{
     steps::{
@@ -24,9 +25,10 @@ pub(crate) fn run_tests() -> Workflow {
     // - script/update_top_ranking_issues/
     // - .github/ISSUE_TEMPLATE/
     // - .github/workflows/  (except .github/workflows/ci.yml)
+    // - extensions/  (these have their own test workflow)
     let should_run_tests = PathCondition::inverted(
         "run_tests",
-        r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))",
+        r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)",
     );
     let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)");
     let should_check_scripts = PathCondition::new(
@@ -60,7 +62,8 @@ pub(crate) fn run_tests() -> Workflow {
         should_check_licences.guard(check_licenses()),
         should_check_scripts.guard(check_scripts()),
     ];
-    let tests_pass = tests_pass(&jobs);
+    let ext_tests = extension_tests();
+    let tests_pass = tests_pass(&jobs, &[&ext_tests.name]);
 
     jobs.push(should_run_tests.guard(check_postgres_and_protobuf_migrations())); // could be more specific here?
 
@@ -91,20 +94,32 @@ pub(crate) fn run_tests() -> Workflow {
             }
             workflow
         })
+        .add_job(ext_tests.name, ext_tests.job)
         .add_job(tests_pass.name, tests_pass.job)
 }
 
+/// Controls which features `orchestrate_impl` includes in the generated script.
+#[derive(PartialEq, Eq)]
+enum OrchestrateTarget {
+    /// For the main Zed repo: includes the cargo package filter and extension
+    /// change detection, but no working-directory scoping.
+    ZedRepo,
+    /// For individual extension repos: scopes changed-file detection to the
+    /// working directory, with no package filter or extension detection.
+    Extension,
+}
+
 // Generates a bash script that checks changed files against regex patterns
 // and sets GitHub output variables accordingly
 pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob {
-    orchestrate_impl(rules, true)
+    orchestrate_impl(rules, OrchestrateTarget::ZedRepo)
 }
 
-pub fn orchestrate_without_package_filter(rules: &[&PathCondition]) -> NamedJob {
-    orchestrate_impl(rules, false)
+pub fn orchestrate_for_extension(rules: &[&PathCondition]) -> NamedJob {
+    orchestrate_impl(rules, OrchestrateTarget::Extension)
 }
 
-fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> NamedJob {
+fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> NamedJob {
     let name = "orchestrate".to_owned();
     let step_name = "filter".to_owned();
     let mut script = String::new();
@@ -121,6 +136,22 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
         fi
         CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
 
+    "#});
+
+    if target == OrchestrateTarget::Extension {
+        script.push_str(indoc::indoc! {r#"
+        # When running from a subdirectory, git diff returns repo-root-relative paths.
+        # Filter to only files within the current working directory and strip the prefix.
+        REPO_SUBDIR="$(git rev-parse --show-prefix)"
+        REPO_SUBDIR="${REPO_SUBDIR%/}"
+        if [ -n "$REPO_SUBDIR" ]; then
+            CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)"
+        fi
+
+    "#});
+    }
+
+    script.push_str(indoc::indoc! {r#"
         check_pattern() {
           local output_name="$1"
           local pattern="$2"
@@ -135,7 +166,7 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
 
     let mut outputs = IndexMap::new();
 
-    if include_package_filter {
+    if target == OrchestrateTarget::ZedRepo {
         script.push_str(indoc::indoc! {r#"
         # Check for changes that require full rebuild (no filter)
         # Direct pushes to main/stable/preview always run full suite
@@ -221,6 +252,16 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
         ));
     }
 
+    if target == OrchestrateTarget::ZedRepo {
+        script.push_str(DETECT_CHANGED_EXTENSIONS_SCRIPT);
+        script.push_str("echo \"changed_extensions=$EXTENSIONS_JSON\" >> \"$GITHUB_OUTPUT\"\n");
+
+        outputs.insert(
+            "changed_extensions".to_owned(),
+            format!("${{{{ steps.{}.outputs.changed_extensions }}}}", step_name),
+        );
+    }
+
     let job = Job::default()
         .runs_on(runners::LINUX_SMALL)
         .with_repository_owner_guard()
@@ -231,7 +272,7 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
     NamedJob { name, job }
 }
 
-pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
+pub fn tests_pass(jobs: &[NamedJob], extra_job_names: &[&str]) -> NamedJob {
     let mut script = String::from(indoc::indoc! {r#"
         set +x
         EXIT_CODE=0
@@ -243,20 +284,26 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
 
     "#});
 
-    let env_entries: Vec<_> = jobs
+    let all_names: Vec<&str> = jobs
         .iter()
-        .map(|job| {
-            let env_name = format!("RESULT_{}", job.name.to_uppercase());
-            let env_value = format!("${{{{ needs.{}.result }}}}", job.name);
+        .map(|job| job.name.as_str())
+        .chain(extra_job_names.iter().copied())
+        .collect();
+
+    let env_entries: Vec<_> = all_names
+        .iter()
+        .map(|name| {
+            let env_name = format!("RESULT_{}", name.to_uppercase());
+            let env_value = format!("${{{{ needs.{}.result }}}}", name);
             (env_name, env_value)
         })
         .collect();
 
     script.push_str(
-        &jobs
+        &all_names
             .iter()
             .zip(env_entries.iter())
-            .map(|(job, (env_name, _))| format!("check_result \"{}\" \"${}\"", job.name, env_name))
+            .map(|(name, (env_name, _))| format!("check_result \"{}\" \"${}\"", name, env_name))
             .collect::<Vec<_>>()
             .join("\n"),
     );
@@ -266,8 +313,9 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
     let job = Job::default()
         .runs_on(runners::LINUX_SMALL)
         .needs(
-            jobs.iter()
-                .map(|j| j.name.to_string())
+            all_names
+                .iter()
+                .map(|name| name.to_string())
                 .collect::<Vec<String>>(),
         )
         .cond(repository_owner_guard_expression(true))
@@ -282,6 +330,19 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
     named::job(job)
 }
 
+/// Bash script snippet that detects changed extension directories from `$CHANGED_FILES`.
+/// Assumes `$CHANGED_FILES` is already set. Sets `$EXTENSIONS_JSON` to a JSON array of
+/// changed extension paths. Callers are responsible for writing the result to `$GITHUB_OUTPUT`.
+pub(crate) const DETECT_CHANGED_EXTENSIONS_SCRIPT: &str = indoc::indoc! {r#"
+    # Detect changed extension directories (excluding extensions/workflows)
+    CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true)
+    if [ -n "$CHANGED_EXTENSIONS" ]; then
+        EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
+    else
+        EXTENSIONS_JSON="[]"
+    fi
+"#};
+
 const TS_QUERY_LS_FILE: &str = "ts_query_ls-x86_64-unknown-linux-gnu.tar.gz";
 const CI_TS_QUERY_RELEASE: &str = "tags/v3.15.1";
 
@@ -298,8 +359,8 @@ pub(crate) fn fetch_ts_query_ls() -> Step<Use> {
 
 pub(crate) fn run_ts_query_ls() -> Step<Run> {
     named::bash(formatdoc!(
-        r#"tar -xf {TS_QUERY_LS_FILE}
-        ./ts_query_ls format --check . || {{
+        r#"tar -xf "$GITHUB_WORKSPACE/{TS_QUERY_LS_FILE}" -C "$GITHUB_WORKSPACE"
+        "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {{
             echo "Found unformatted queries, please format them with ts_query_ls."
             echo "For easy use, install the Tree-sitter query extension:"
             echo "zed://extension/tree-sitter-query"
@@ -692,3 +753,26 @@ pub(crate) fn check_scripts() -> NamedJob {
             .add_step(check_xtask_workflows()),
     )
 }
+
+fn extension_tests() -> NamedJob<UsesJob> {
+    let job = Job::default()
+        .needs(vec!["orchestrate".to_owned()])
+        .cond(Expression::new(
+            "needs.orchestrate.outputs.changed_extensions != '[]'",
+        ))
+        .permissions(Permissions::default().contents(Level::Read))
+        .strategy(
+            Strategy::default()
+                .fail_fast(false)
+                // TODO: Remove the limit. We currently need this to workaround the concurrency group issue
+                // where different matrix jobs would be placed in the same concurrency group and thus cancelled.
+                .max_parallel(1u32)
+                .matrix(json!({
+                    "extension": "${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}"
+                })),
+        )
+        .uses_local(".github/workflows/extension_tests.yml")
+        .with(Input::default().add("working-directory", "${{ matrix.extension }}"));
+
+    named::job(job)
+}

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

@@ -10,7 +10,7 @@ pub(crate) fn use_clang(job: Job) -> Job {
 
 const SCCACHE_R2_BUCKET: &str = "sccache-zed";
 
-const BASH_SHELL: &str = "bash -euxo pipefail {0}";
+pub(crate) const BASH_SHELL: &str = "bash -euxo pipefail {0}";
 // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell
 pub const PWSH_SHELL: &str = "pwsh";
 
@@ -24,13 +24,6 @@ pub(crate) fn cargo_nextest(platform: Platform) -> Nextest {
 }
 
 impl Nextest {
-    pub(crate) fn with_target(mut self, target: &str) -> Step<Run> {
-        if let Some(nextest_command) = self.0.value.run.as_mut() {
-            nextest_command.push_str(&format!(r#" --target "{target}""#));
-        }
-        self.into()
-    }
-
     #[allow(dead_code)]
     pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self {
         if let Some(nextest_command) = self.0.value.run.as_mut() {
@@ -131,22 +124,12 @@ impl From<CheckoutStep> for Step<Use> {
                 FetchDepth::Full => step.add_with(("fetch-depth", 0)),
                 FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)),
             })
-            .map(|step| match value.token {
-                Some(token) => step.add_with(("token", token)),
-                None => step,
-            })
-            .map(|step| match value.path {
-                Some(path) => step.add_with(("path", path)),
-                None => step,
-            })
-            .map(|step| match value.repository {
-                Some(repository) => step.add_with(("repository", repository)),
-                None => step,
-            })
-            .map(|step| match value.ref_ {
-                Some(ref_) => step.add_with(("ref", ref_)),
-                None => step,
+            .when_some(value.path, |step, path| step.add_with(("path", path)))
+            .when_some(value.repository, |step, repository| {
+                step.add_with(("repository", repository))
             })
+            .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_)))
+            .when_some(value.token, |step, token| step.add_with(("token", token)))
     }
 }
 

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

@@ -156,14 +156,31 @@ pub(crate) struct StepOutput {
 
 impl StepOutput {
     pub fn new<T>(step: &Step<T>, name: &'static str) -> Self {
-        Self {
-            name,
-            step_id: step
-                .value
-                .id
-                .clone()
-                .expect("Steps that produce outputs must have an ID"),
-        }
+        let step_id = step
+            .value
+            .id
+            .clone()
+            .expect("Steps that produce outputs must have an ID");
+
+        assert!(
+            step.value
+                .run
+                .as_ref()
+                .is_none_or(|run_command| run_command.contains(name)),
+            "Step Output name {name} must occur at least once in run command with ID {step_id}!"
+        );
+
+        Self { name, step_id }
+    }
+
+    pub fn new_unchecked<T>(step: &Step<T>, name: &'static str) -> Self {
+        let step_id = step
+            .value
+            .id
+            .clone()
+            .expect("Steps that produce outputs must have an ID");
+
+        Self { name, step_id }
     }
 
     pub fn expr(&self) -> String {

typos.toml 🔗

@@ -92,6 +92,8 @@ extend-ignore-re = [
     # AMD GPU Services
     "ags",
     # AMD GPU Services
-    "AGS"
+    "AGS",
+    # Yarn Plug'n'Play
+    "PnP"
 ]
 check-filename = true