agent_panel.rs

   1use std::{
   2    path::PathBuf,
   3    rc::Rc,
   4    sync::{
   5        Arc,
   6        atomic::{AtomicBool, Ordering},
   7    },
   8    time::Duration,
   9};
  10
  11use acp_thread::{AcpThread, MentionUri, ThreadStatus};
  12use agent::{ContextServerRegistry, SharedThread, ThreadStore};
  13use agent_client_protocol as acp;
  14use agent_servers::AgentServer;
  15use collections::HashSet;
  16use db::kvp::{Dismissable, KeyValueStore};
  17use itertools::Itertools;
  18use project::AgentId;
  19use serde::{Deserialize, Serialize};
  20use settings::{LanguageModelProviderSetting, LanguageModelSelection};
  21
  22use zed_actions::agent::{
  23    AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent,
  24    ResolveConflictsWithAgent, ReviewBranchDiff,
  25};
  26
  27use crate::agent_connection_store::AgentConnectionStore;
  28use crate::thread_metadata_store::ThreadMetadataStore;
  29use crate::{
  30    AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
  31    Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
  32    OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetTrialEndUpsell, ResetTrialUpsell,
  33    StartThreadIn, ToggleNewThreadMenu, ToggleOptionsMenu,
  34    agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
  35    conversation_view::{AcpThreadViewEvent, ThreadView},
  36    thread_branch_picker::ThreadBranchPicker,
  37    thread_worktree_picker::ThreadWorktreePicker,
  38    ui::EndTrialUpsell,
  39};
  40use crate::{
  41    Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
  42    NewNativeAgentThreadFromSummary,
  43};
  44use crate::{DEFAULT_THREAD_TITLE, ui::AcpOnboardingModal};
  45use crate::{ExpandMessageEditor, ManageProfiles};
  46use agent_settings::{AgentSettings, WindowLayout};
  47use ai_onboarding::AgentPanelOnboarding;
  48use anyhow::{Result, anyhow};
  49use client::UserStore;
  50use cloud_api_types::Plan;
  51use collections::HashMap;
  52use editor::Editor;
  53use extension::ExtensionEvents;
  54use extension_host::ExtensionStore;
  55use fs::Fs;
  56use gpui::{
  57    Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
  58    Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, Global, KeyContext,
  59    Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
  60};
  61use language::LanguageRegistry;
  62use language_model::LanguageModelRegistry;
  63use project::git_store::{GitStoreEvent, RepositoryEvent};
  64use project::project_settings::ProjectSettings;
  65use project::{Project, ProjectPath, Worktree, linked_worktree_short_name};
  66use prompt_store::{PromptStore, UserPromptId};
  67use remote::RemoteConnectionOptions;
  68use rules_library::{RulesLibrary, open_rules_library};
  69use settings::TerminalDockPosition;
  70use settings::{Settings, update_settings_file};
  71use terminal::terminal_settings::TerminalSettings;
  72use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
  73use theme_settings::ThemeSettings;
  74use ui::{
  75    Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
  76    PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
  77};
  78use util::ResultExt as _;
  79use workspace::{
  80    CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList,
  81    ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
  82    dock::{DockPosition, Panel, PanelEvent},
  83};
  84use zed_actions::{
  85    DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
  86    agent::{OpenAcpOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding},
  87    assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
  88};
  89
  90const AGENT_PANEL_KEY: &str = "agent_panel";
  91const MIN_PANEL_WIDTH: Pixels = px(300.);
  92const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
  93
  94#[derive(Serialize, Deserialize)]
  95struct LastUsedAgent {
  96    agent: Agent,
  97}
  98
  99/// Reads the most recently used agent across all workspaces. Used as a fallback
 100/// when opening a workspace that has no per-workspace agent preference yet.
 101fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option<Agent> {
 102    kvp.read_kvp(LAST_USED_AGENT_KEY)
 103        .log_err()
 104        .flatten()
 105        .and_then(|json| serde_json::from_str::<LastUsedAgent>(&json).log_err())
 106        .map(|entry| entry.agent)
 107}
 108
 109async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) {
 110    if let Some(json) = serde_json::to_string(&LastUsedAgent { agent }).log_err() {
 111        kvp.write_kvp(LAST_USED_AGENT_KEY.to_string(), json)
 112            .await
 113            .log_err();
 114    }
 115}
 116
 117fn read_serialized_panel(
 118    workspace_id: workspace::WorkspaceId,
 119    kvp: &KeyValueStore,
 120) -> Option<SerializedAgentPanel> {
 121    let scope = kvp.scoped(AGENT_PANEL_KEY);
 122    let key = i64::from(workspace_id).to_string();
 123    scope
 124        .read(&key)
 125        .log_err()
 126        .flatten()
 127        .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
 128}
 129
 130async fn save_serialized_panel(
 131    workspace_id: workspace::WorkspaceId,
 132    panel: SerializedAgentPanel,
 133    kvp: KeyValueStore,
 134) -> Result<()> {
 135    let scope = kvp.scoped(AGENT_PANEL_KEY);
 136    let key = i64::from(workspace_id).to_string();
 137    scope.write(key, serde_json::to_string(&panel)?).await?;
 138    Ok(())
 139}
 140
 141/// Migration: reads the original single-panel format stored under the
 142/// `"agent_panel"` KVP key before per-workspace keying was introduced.
 143fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPanel> {
 144    kvp.read_kvp(AGENT_PANEL_KEY)
 145        .log_err()
 146        .flatten()
 147        .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
 148}
 149
 150#[derive(Serialize, Deserialize, Debug)]
 151struct SerializedAgentPanel {
 152    selected_agent: Option<Agent>,
 153    #[serde(default)]
 154    last_active_thread: Option<SerializedActiveThread>,
 155    #[serde(default)]
 156    start_thread_in: Option<StartThreadIn>,
 157}
 158
 159#[derive(Serialize, Deserialize, Debug)]
 160struct SerializedActiveThread {
 161    session_id: String,
 162    agent_type: Agent,
 163    title: Option<String>,
 164    work_dirs: Option<SerializedPathList>,
 165}
 166
 167pub fn init(cx: &mut App) {
 168    cx.observe_new(
 169        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
 170            workspace
 171                .register_action(|workspace, action: &NewThread, window, cx| {
 172                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 173                        panel.update(cx, |panel, cx| panel.new_thread(action, window, cx));
 174                        workspace.focus_panel::<AgentPanel>(window, cx);
 175                    }
 176                })
 177                .register_action(
 178                    |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| {
 179                        if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 180                            panel.update(cx, |panel, cx| {
 181                                panel.new_native_agent_thread_from_summary(action, window, cx)
 182                            });
 183                            workspace.focus_panel::<AgentPanel>(window, cx);
 184                        }
 185                    },
 186                )
 187                .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
 188                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 189                        workspace.focus_panel::<AgentPanel>(window, cx);
 190                        panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
 191                    }
 192                })
 193                .register_action(|workspace, _: &OpenSettings, window, cx| {
 194                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 195                        workspace.focus_panel::<AgentPanel>(window, cx);
 196                        panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
 197                    }
 198                })
 199                .register_action(|workspace, _action: &NewExternalAgentThread, window, cx| {
 200                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 201                        workspace.focus_panel::<AgentPanel>(window, cx);
 202                        panel.update(cx, |panel, cx| {
 203                            let id = panel.create_draft(window, cx);
 204                            panel.activate_draft(id, true, window, cx);
 205                        });
 206                    }
 207                })
 208                .register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
 209                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 210                        workspace.focus_panel::<AgentPanel>(window, cx);
 211                        panel.update(cx, |panel, cx| {
 212                            panel.deploy_rules_library(action, window, cx)
 213                        });
 214                    }
 215                })
 216                .register_action(|workspace, _: &Follow, window, cx| {
 217                    workspace.follow(CollaboratorId::Agent, window, cx);
 218                })
 219                .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
 220                    let thread = workspace
 221                        .panel::<AgentPanel>(cx)
 222                        .and_then(|panel| panel.read(cx).active_conversation_view().cloned())
 223                        .and_then(|conversation| {
 224                            conversation
 225                                .read(cx)
 226                                .active_thread()
 227                                .map(|r| r.read(cx).thread.clone())
 228                        });
 229
 230                    if let Some(thread) = thread {
 231                        AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
 232                    }
 233                })
 234                .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
 235                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 236                        workspace.focus_panel::<AgentPanel>(window, cx);
 237                        panel.update(cx, |panel, cx| {
 238                            panel.toggle_options_menu(&ToggleOptionsMenu, window, cx);
 239                        });
 240                    }
 241                })
 242                .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
 243                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 244                        workspace.focus_panel::<AgentPanel>(window, cx);
 245                        panel.update(cx, |panel, cx| {
 246                            panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
 247                        });
 248                    }
 249                })
 250                .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
 251                    AcpOnboardingModal::toggle(workspace, window, cx)
 252                })
 253                .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
 254                    window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
 255                    window.refresh();
 256                })
 257                .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| {
 258                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 259                        panel.update(cx, |panel, _| {
 260                            panel
 261                                .new_user_onboarding_upsell_dismissed
 262                                .store(false, Ordering::Release);
 263                        });
 264                    }
 265                    OnboardingUpsell::set_dismissed(false, cx);
 266                })
 267                .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
 268                    TrialEndUpsell::set_dismissed(false, cx);
 269                })
 270                .register_action(|workspace, _: &ResetAgentZoom, window, cx| {
 271                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 272                        panel.update(cx, |panel, cx| {
 273                            panel.reset_agent_zoom(window, cx);
 274                        });
 275                    }
 276                })
 277                .register_action(|workspace, _: &CopyThreadToClipboard, window, cx| {
 278                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 279                        panel.update(cx, |panel, cx| {
 280                            panel.copy_thread_to_clipboard(window, cx);
 281                        });
 282                    }
 283                })
 284                .register_action(|workspace, _: &LoadThreadFromClipboard, window, cx| {
 285                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 286                        workspace.focus_panel::<AgentPanel>(window, cx);
 287                        panel.update(cx, |panel, cx| {
 288                            panel.load_thread_from_clipboard(window, cx);
 289                        });
 290                    }
 291                })
 292                .register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
 293                    let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 294                        return;
 295                    };
 296
 297                    let mention_uri = MentionUri::GitDiff {
 298                        base_ref: action.base_ref.to_string(),
 299                    };
 300                    let diff_uri = mention_uri.to_uri().to_string();
 301
 302                    let content_blocks = vec![
 303                        acp::ContentBlock::Text(acp::TextContent::new(
 304                            "Please review this branch diff carefully. Point out any issues, \
 305                             potential bugs, or improvement opportunities you find.\n\n"
 306                                .to_string(),
 307                        )),
 308                        acp::ContentBlock::Resource(acp::EmbeddedResource::new(
 309                            acp::EmbeddedResourceResource::TextResourceContents(
 310                                acp::TextResourceContents::new(
 311                                    action.diff_text.to_string(),
 312                                    diff_uri,
 313                                ),
 314                            ),
 315                        )),
 316                    ];
 317
 318                    workspace.focus_panel::<AgentPanel>(window, cx);
 319
 320                    panel.update(cx, |panel, cx| {
 321                        panel.external_thread(
 322                            None,
 323                            None,
 324                            None,
 325                            None,
 326                            Some(AgentInitialContent::ContentBlock {
 327                                blocks: content_blocks,
 328                                auto_submit: true,
 329                            }),
 330                            true,
 331                            window,
 332                            cx,
 333                        );
 334                    });
 335                })
 336                .register_action(
 337                    |workspace, action: &ResolveConflictsWithAgent, window, cx| {
 338                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 339                            return;
 340                        };
 341
 342                        let content_blocks = build_conflict_resolution_prompt(&action.conflicts);
 343
 344                        workspace.focus_panel::<AgentPanel>(window, cx);
 345
 346                        panel.update(cx, |panel, cx| {
 347                            panel.external_thread(
 348                                None,
 349                                None,
 350                                None,
 351                                None,
 352                                Some(AgentInitialContent::ContentBlock {
 353                                    blocks: content_blocks,
 354                                    auto_submit: true,
 355                                }),
 356                                true,
 357                                window,
 358                                cx,
 359                            );
 360                        });
 361                    },
 362                )
 363                .register_action(
 364                    |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| {
 365                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 366                            return;
 367                        };
 368
 369                        let content_blocks =
 370                            build_conflicted_files_resolution_prompt(&action.conflicted_file_paths);
 371
 372                        workspace.focus_panel::<AgentPanel>(window, cx);
 373
 374                        panel.update(cx, |panel, cx| {
 375                            panel.external_thread(
 376                                None,
 377                                None,
 378                                None,
 379                                None,
 380                                Some(AgentInitialContent::ContentBlock {
 381                                    blocks: content_blocks,
 382                                    auto_submit: true,
 383                                }),
 384                                true,
 385                                window,
 386                                cx,
 387                            );
 388                        });
 389                    },
 390                )
 391                .register_action(|workspace, action: &StartThreadIn, window, cx| {
 392                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 393                        panel.update(cx, |panel, cx| {
 394                            panel.set_start_thread_in(action, window, cx);
 395                        });
 396                    }
 397                })
 398                .register_action(|workspace, _: &CycleStartThreadIn, window, cx| {
 399                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
 400                        panel.update(cx, |panel, cx| {
 401                            panel.cycle_start_thread_in(window, cx);
 402                        });
 403                    }
 404                })
 405                .register_action(
 406                    |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| {
 407                        let active_editor = workspace
 408                            .active_item(cx)
 409                            .and_then(|item| item.act_as::<Editor>(cx));
 410                        let has_editor_selection = active_editor.is_some_and(|editor| {
 411                            editor.update(cx, |editor, cx| {
 412                                editor.has_non_empty_selection(&editor.display_snapshot(cx))
 413                            })
 414                        });
 415
 416                        let has_terminal_selection = workspace
 417                            .active_item(cx)
 418                            .and_then(|item| item.act_as::<TerminalView>(cx))
 419                            .is_some_and(|terminal_view| {
 420                                terminal_view
 421                                    .read(cx)
 422                                    .terminal()
 423                                    .read(cx)
 424                                    .last_content
 425                                    .selection_text
 426                                    .as_ref()
 427                                    .is_some_and(|text| !text.is_empty())
 428                            });
 429
 430                        let has_terminal_panel_selection =
 431                            workspace.panel::<TerminalPanel>(cx).is_some_and(|panel| {
 432                                let position = match TerminalSettings::get_global(cx).dock {
 433                                    TerminalDockPosition::Left => DockPosition::Left,
 434                                    TerminalDockPosition::Bottom => DockPosition::Bottom,
 435                                    TerminalDockPosition::Right => DockPosition::Right,
 436                                };
 437                                let dock_is_open =
 438                                    workspace.dock_at_position(position).read(cx).is_open();
 439                                dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty()
 440                            });
 441
 442                        if !has_editor_selection
 443                            && !has_terminal_selection
 444                            && !has_terminal_panel_selection
 445                        {
 446                            return;
 447                        }
 448
 449                        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
 450                            return;
 451                        };
 452
 453                        if !panel.focus_handle(cx).contains_focused(window, cx) {
 454                            workspace.toggle_panel_focus::<AgentPanel>(window, cx);
 455                        }
 456
 457                        panel.update(cx, |_, cx| {
 458                            cx.defer_in(window, move |panel, window, cx| {
 459                                if let Some(conversation_view) = panel.active_conversation_view() {
 460                                    conversation_view.update(cx, |conversation_view, cx| {
 461                                        conversation_view.insert_selections(window, cx);
 462                                    });
 463                                }
 464                            });
 465                        });
 466                    },
 467                );
 468        },
 469    )
 470    .detach();
 471}
 472
 473fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
 474    let mention_uri = MentionUri::MergeConflict {
 475        file_path: conflict.file_path.clone(),
 476    };
 477    acp::ContentBlock::Resource(acp::EmbeddedResource::new(
 478        acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new(
 479            conflict.conflict_text.clone(),
 480            mention_uri.to_uri().to_string(),
 481        )),
 482    ))
 483}
 484
 485fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec<acp::ContentBlock> {
 486    if conflicts.is_empty() {
 487        return Vec::new();
 488    }
 489
 490    let mut blocks = Vec::new();
 491
 492    if conflicts.len() == 1 {
 493        let conflict = &conflicts[0];
 494
 495        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
 496            "Please resolve the following merge conflict in ",
 497        )));
 498        let mention = MentionUri::File {
 499            abs_path: PathBuf::from(conflict.file_path.clone()),
 500        };
 501        blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
 502            mention.name(),
 503            mention.to_uri(),
 504        )));
 505
 506        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
 507            indoc::formatdoc!(
 508                "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs).
 509
 510                Analyze both versions carefully and resolve the conflict by editing \
 511                the file directly. Choose the resolution that best preserves the intent \
 512                of both changes, or combine them if appropriate.
 513
 514                ",
 515                ours = conflict.ours_branch_name,
 516                theirs = conflict.theirs_branch_name,
 517            ),
 518        )));
 519    } else {
 520        let n = conflicts.len();
 521        let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
 522        let ours = &conflicts[0].ours_branch_name;
 523        let theirs = &conflicts[0].theirs_branch_name;
 524        blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
 525            indoc::formatdoc!(
 526                "Please resolve all {n} merge conflicts below.
 527
 528                The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs).
 529
 530                For each conflict, analyze both versions carefully and resolve them \
 531                by editing the file{suffix} directly. Choose resolutions that best preserve \
 532                the intent of both changes, or combine them if appropriate.
 533
 534                ",
 535                suffix = if unique_files.len() > 1 { "s" } else { "" },
 536            ),
 537        )));
 538    }
 539
 540    for conflict in conflicts {
 541        blocks.push(conflict_resource_block(conflict));
 542    }
 543
 544    blocks
 545}
 546
 547fn build_conflicted_files_resolution_prompt(
 548    conflicted_file_paths: &[String],
 549) -> Vec<acp::ContentBlock> {
 550    if conflicted_file_paths.is_empty() {
 551        return Vec::new();
 552    }
 553
 554    let instruction = indoc::indoc!(
 555        "The following files have unresolved merge conflicts. Please open each \
 556         file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \
 557         and resolve every conflict by editing the files directly.
 558
 559         Choose resolutions that best preserve the intent of both changes, \
 560         or combine them if appropriate.
 561
 562         Files with conflicts:
 563         ",
 564    );
 565
 566    let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))];
 567    for path in conflicted_file_paths {
 568        let mention = MentionUri::File {
 569            abs_path: PathBuf::from(path),
 570        };
 571        content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
 572            mention.name(),
 573            mention.to_uri(),
 574        )));
 575        content.push(acp::ContentBlock::Text(acp::TextContent::new("\n")));
 576    }
 577    content
 578}
 579
 580/// Unique identifier for a sidebar draft thread. Not persisted across restarts.
 581/// IDs are globally unique across all AgentPanel instances within the same app.
 582#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
 583pub struct DraftId(pub usize);
 584
 585#[derive(Default)]
 586struct DraftIdCounter(usize);
 587
 588impl Global for DraftIdCounter {}
 589
 590impl DraftId {
 591    fn next(cx: &mut App) -> Self {
 592        let counter = cx.default_global::<DraftIdCounter>();
 593        let id = counter.0;
 594        counter.0 += 1;
 595        Self(id)
 596    }
 597}
 598
 599enum ActiveView {
 600    Uninitialized,
 601    AgentThread {
 602        conversation_view: Entity<ConversationView>,
 603    },
 604    Configuration,
 605}
 606
 607enum WhichFontSize {
 608    AgentFont,
 609    None,
 610}
 611
 612struct StartThreadInLabel {
 613    prefix: Option<SharedString>,
 614    label: SharedString,
 615    suffix: Option<SharedString>,
 616}
 617
 618impl StartThreadIn {
 619    fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel {
 620        match self {
 621            Self::LocalProject => {
 622                let suffix = project.active_repository(cx).and_then(|repo| {
 623                    let repo = repo.read(cx);
 624                    let work_dir = &repo.original_repo_abs_path;
 625                    let visible_paths: Vec<_> = project
 626                        .visible_worktrees(cx)
 627                        .map(|wt| wt.read(cx).abs_path().to_path_buf())
 628                        .collect();
 629
 630                    for linked in repo.linked_worktrees() {
 631                        if visible_paths.contains(&linked.path) {
 632                            return Some(SharedString::from(format!(
 633                                "({})",
 634                                linked.display_name()
 635                            )));
 636                        }
 637                    }
 638
 639                    if let Some(name) = linked_worktree_short_name(
 640                        repo.original_repo_abs_path.as_ref(),
 641                        repo.work_directory_abs_path.as_ref(),
 642                    ) {
 643                        if visible_paths
 644                            .iter()
 645                            .any(|p| p.as_path() == repo.work_directory_abs_path.as_ref())
 646                        {
 647                            return Some(SharedString::from(format!("({})", name)));
 648                        }
 649                    }
 650
 651                    if visible_paths
 652                        .iter()
 653                        .any(|p| p.as_path() == work_dir.as_ref())
 654                    {
 655                        return Some("(main)".into());
 656                    }
 657
 658                    None
 659                });
 660
 661                StartThreadInLabel {
 662                    prefix: None,
 663                    label: "Current Worktree".into(),
 664                    suffix,
 665                }
 666            }
 667            Self::NewWorktree {
 668                worktree_name: Some(worktree_name),
 669                ..
 670            } => StartThreadInLabel {
 671                prefix: Some("New:".into()),
 672                label: worktree_name.clone().into(),
 673                suffix: None,
 674            },
 675            Self::NewWorktree { .. } => StartThreadInLabel {
 676                prefix: None,
 677                label: "New Git Worktree".into(),
 678                suffix: None,
 679            },
 680            Self::LinkedWorktree { display_name, .. } => StartThreadInLabel {
 681                prefix: Some("From:".into()),
 682                label: display_name.clone().into(),
 683                suffix: None,
 684            },
 685        }
 686    }
 687
 688    fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option<StartThreadInLabel> {
 689        match self {
 690            Self::NewWorktree { branch_target, .. } => {
 691                let (branch_name, is_occupied) = match branch_target {
 692                    NewWorktreeBranchTarget::CurrentBranch => {
 693                        let name: SharedString = if project.repositories(cx).len() > 1 {
 694                            "current branches".into()
 695                        } else {
 696                            project
 697                                .active_repository(cx)
 698                                .and_then(|repo| {
 699                                    repo.read(cx)
 700                                        .branch
 701                                        .as_ref()
 702                                        .map(|branch| SharedString::from(branch.name().to_string()))
 703                                })
 704                                .unwrap_or_else(|| "HEAD".into())
 705                        };
 706                        (name, false)
 707                    }
 708                    NewWorktreeBranchTarget::ExistingBranch { name } => {
 709                        let occupied = Self::is_branch_occupied(name, project, cx);
 710                        (name.clone().into(), occupied)
 711                    }
 712                    NewWorktreeBranchTarget::CreateBranch {
 713                        from_ref: Some(from_ref),
 714                        ..
 715                    } => {
 716                        let occupied = Self::is_branch_occupied(from_ref, project, cx);
 717                        (from_ref.clone().into(), occupied)
 718                    }
 719                    NewWorktreeBranchTarget::CreateBranch { name, .. } => {
 720                        (name.clone().into(), false)
 721                    }
 722                };
 723
 724                let prefix = if is_occupied {
 725                    Some("New From:".into())
 726                } else {
 727                    None
 728                };
 729
 730                Some(StartThreadInLabel {
 731                    prefix,
 732                    label: branch_name,
 733                    suffix: None,
 734                })
 735            }
 736            _ => None,
 737        }
 738    }
 739
 740    fn is_branch_occupied(branch_name: &str, project: &Project, cx: &App) -> bool {
 741        project.repositories(cx).values().any(|repo| {
 742            repo.read(cx)
 743                .linked_worktrees
 744                .iter()
 745                .any(|wt| wt.branch_name() == Some(branch_name))
 746        })
 747    }
 748}
 749
 750#[derive(Clone, Debug)]
 751#[allow(dead_code)]
 752pub enum WorktreeCreationStatus {
 753    Creating,
 754    Error(SharedString),
 755}
 756
 757#[derive(Clone, Debug)]
 758enum WorktreeCreationArgs {
 759    New {
 760        worktree_name: Option<String>,
 761        branch_target: NewWorktreeBranchTarget,
 762    },
 763    Linked {
 764        worktree_path: PathBuf,
 765    },
 766}
 767
 768impl ActiveView {
 769    pub fn which_font_size_used(&self) -> WhichFontSize {
 770        match self {
 771            ActiveView::Uninitialized | ActiveView::AgentThread { .. } => WhichFontSize::AgentFont,
 772            ActiveView::Configuration => WhichFontSize::None,
 773        }
 774    }
 775}
 776
 777pub struct AgentPanel {
 778    workspace: WeakEntity<Workspace>,
 779    /// Workspace id is used as a database key
 780    workspace_id: Option<WorkspaceId>,
 781    user_store: Entity<UserStore>,
 782    project: Entity<Project>,
 783    fs: Arc<dyn Fs>,
 784    language_registry: Arc<LanguageRegistry>,
 785    thread_store: Entity<ThreadStore>,
 786    prompt_store: Option<Entity<PromptStore>>,
 787    connection_store: Entity<AgentConnectionStore>,
 788    context_server_registry: Entity<ContextServerRegistry>,
 789    configuration: Option<Entity<AgentConfiguration>>,
 790    configuration_subscription: Option<Subscription>,
 791    focus_handle: FocusHandle,
 792    active_view: ActiveView,
 793    previous_view: Option<ActiveView>,
 794    background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
 795    draft_threads: HashMap<DraftId, Entity<ConversationView>>,
 796    new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
 797    start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
 798    thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
 799    agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
 800    _extension_subscription: Option<Subscription>,
 801    _project_subscription: Subscription,
 802    _git_store_subscription: Subscription,
 803    zoomed: bool,
 804    pending_serialization: Option<Task<Result<()>>>,
 805    new_user_onboarding: Entity<AgentPanelOnboarding>,
 806    new_user_onboarding_upsell_dismissed: AtomicBool,
 807    agent_layout_onboarding: Entity<ai_onboarding::AgentLayoutOnboarding>,
 808    agent_layout_onboarding_dismissed: AtomicBool,
 809    selected_agent: Agent,
 810    start_thread_in: StartThreadIn,
 811    worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>,
 812    _thread_view_subscription: Option<Subscription>,
 813    _active_thread_focus_subscription: Option<Subscription>,
 814    _worktree_creation_task: Option<Task<()>>,
 815    show_trust_workspace_message: bool,
 816    _active_view_observation: Option<Subscription>,
 817}
 818
 819impl AgentPanel {
 820    fn serialize(&mut self, cx: &mut App) {
 821        let Some(workspace_id) = self.workspace_id else {
 822            return;
 823        };
 824
 825        let selected_agent = self.selected_agent.clone();
 826        let start_thread_in = Some(self.start_thread_in.clone());
 827
 828        let last_active_thread = self.active_agent_thread(cx).map(|thread| {
 829            let thread = thread.read(cx);
 830            let title = thread.title();
 831            let work_dirs = thread.work_dirs().cloned();
 832            SerializedActiveThread {
 833                session_id: thread.session_id().0.to_string(),
 834                agent_type: self.selected_agent.clone(),
 835                title: title.map(|t| t.to_string()),
 836                work_dirs: work_dirs.map(|dirs| dirs.serialize()),
 837            }
 838        });
 839
 840        let kvp = KeyValueStore::global(cx);
 841        self.pending_serialization = Some(cx.background_spawn(async move {
 842            save_serialized_panel(
 843                workspace_id,
 844                SerializedAgentPanel {
 845                    selected_agent: Some(selected_agent),
 846                    last_active_thread,
 847                    start_thread_in,
 848                },
 849                kvp,
 850            )
 851            .await?;
 852            anyhow::Ok(())
 853        }));
 854    }
 855
 856    pub fn load(
 857        workspace: WeakEntity<Workspace>,
 858        mut cx: AsyncWindowContext,
 859    ) -> Task<Result<Entity<Self>>> {
 860        let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
 861        let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
 862        cx.spawn(async move |cx| {
 863            let prompt_store = match prompt_store {
 864                Ok(prompt_store) => prompt_store.await.ok(),
 865                Err(_) => None,
 866            };
 867            let workspace_id = workspace
 868                .read_with(cx, |workspace, _| workspace.database_id())
 869                .ok()
 870                .flatten();
 871
 872            let (serialized_panel, global_last_used_agent) = cx
 873                .background_spawn(async move {
 874                    match kvp {
 875                        Some(kvp) => {
 876                            let panel = workspace_id
 877                                .and_then(|id| read_serialized_panel(id, &kvp))
 878                                .or_else(|| read_legacy_serialized_panel(&kvp));
 879                            let global_agent = read_global_last_used_agent(&kvp);
 880                            (panel, global_agent)
 881                        }
 882                        None => (None, None),
 883                    }
 884                })
 885                .await;
 886
 887            let last_active_thread = if let Some(thread_info) = serialized_panel
 888                .as_ref()
 889                .and_then(|p| p.last_active_thread.as_ref())
 890            {
 891                let session_id = acp::SessionId::new(thread_info.session_id.clone());
 892                let has_metadata = cx
 893                    .update(|_window, cx| {
 894                        let store = ThreadMetadataStore::global(cx);
 895                        store.read(cx).entry(&session_id).is_some()
 896                    })
 897                    .unwrap_or(false);
 898                if has_metadata {
 899                    Some(thread_info)
 900                } else {
 901                    log::warn!(
 902                        "last active thread {} has no metadata, skipping restoration",
 903                        thread_info.session_id
 904                    );
 905                    None
 906                }
 907            } else {
 908                None
 909            };
 910
 911            let panel = workspace.update_in(cx, |workspace, window, cx| {
 912                let panel =
 913                    cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
 914
 915                panel.update(cx, |panel, cx| {
 916                    let is_via_collab = panel.project.read(cx).is_via_collab();
 917
 918                    // Only apply a non-native global fallback to local projects.
 919                    // Collab workspaces only support NativeAgent, so inheriting a
 920                    // custom agent would cause set_active → new_agent_thread_inner
 921                    // to bypass the collab guard in external_thread.
 922                    let global_fallback = global_last_used_agent
 923                        .filter(|agent| !is_via_collab || agent.is_native());
 924
 925                    if let Some(serialized_panel) = &serialized_panel {
 926                        if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
 927                            panel.selected_agent = selected_agent;
 928                        } else if let Some(agent) = global_fallback {
 929                            panel.selected_agent = agent;
 930                        }
 931                        if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
 932                            let is_valid = match &start_thread_in {
 933                                StartThreadIn::LocalProject => true,
 934                                StartThreadIn::NewWorktree { .. } => {
 935                                    let project = panel.project.read(cx);
 936                                    !project.is_via_collab()
 937                                }
 938                                StartThreadIn::LinkedWorktree { path, .. } => {
 939                                    path.exists()
 940                                }
 941                            };
 942                            if is_valid {
 943                                panel.start_thread_in = start_thread_in.clone();
 944                            } else {
 945                                log::info!(
 946                                    "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
 947                                    start_thread_in,
 948                                );
 949                            }
 950                        }
 951                    } else if let Some(agent) = global_fallback {
 952                        panel.selected_agent = agent;
 953                    }
 954                    cx.notify();
 955                });
 956
 957                if let Some(thread_info) = last_active_thread {
 958                    let agent = thread_info.agent_type.clone();
 959                    panel.update(cx, |panel, cx| {
 960                        panel.selected_agent = agent.clone();
 961                        panel.load_agent_thread(
 962                            agent,
 963                            thread_info.session_id.clone().into(),
 964                            thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
 965                            thread_info.title.as_ref().map(|t| t.clone().into()),
 966                            false,
 967                            window,
 968                            cx,
 969                        );
 970                    });
 971                }
 972                panel
 973            })?;
 974
 975            Ok(panel)
 976        })
 977    }
 978
 979    pub(crate) fn new(
 980        workspace: &Workspace,
 981        prompt_store: Option<Entity<PromptStore>>,
 982        _window: &mut Window,
 983        cx: &mut Context<Self>,
 984    ) -> Self {
 985        let fs = workspace.app_state().fs.clone();
 986        let user_store = workspace.app_state().user_store.clone();
 987        let project = workspace.project();
 988        let language_registry = project.read(cx).languages().clone();
 989        let client = workspace.client().clone();
 990        let workspace_id = workspace.database_id();
 991        let workspace = workspace.weak_handle();
 992
 993        let context_server_registry =
 994            cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 995
 996        let thread_store = ThreadStore::global(cx);
 997
 998        let active_view = ActiveView::Uninitialized;
 999
1000        let weak_panel = cx.entity().downgrade();
1001        let onboarding = cx.new(|cx| {
1002            AgentPanelOnboarding::new(
1003                user_store.clone(),
1004                client,
1005                move |_window, cx| {
1006                    weak_panel
1007                        .update(cx, |panel, cx| {
1008                            panel.dismiss_ai_onboarding(cx);
1009                        })
1010                        .ok();
1011                },
1012                cx,
1013            )
1014        });
1015
1016        let weak_panel = cx.entity().downgrade();
1017
1018        let layout = AgentSettings::get_layout(cx);
1019        let is_agent_layout = matches!(layout, WindowLayout::Agent(_));
1020
1021        let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding {
1022            use_agent_layout: Arc::new({
1023                let fs = fs.clone();
1024                let weak_panel = weak_panel.clone();
1025                move |_window, cx| {
1026                    AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
1027                    weak_panel
1028                        .update(cx, |panel, cx| {
1029                            panel.dismiss_agent_layout_onboarding(cx);
1030                        })
1031                        .ok();
1032                }
1033            }),
1034            revert_to_editor_layout: Arc::new({
1035                let fs = fs.clone();
1036                let weak_panel = weak_panel.clone();
1037                move |_window, cx| {
1038                    AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
1039                    weak_panel
1040                        .update(cx, |panel, cx| {
1041                            panel.dismiss_agent_layout_onboarding(cx);
1042                        })
1043                        .ok();
1044                }
1045            }),
1046            dismissed: Arc::new(move |_window, cx| {
1047                weak_panel
1048                    .update(cx, |panel, cx| {
1049                        panel.dismiss_agent_layout_onboarding(cx);
1050                    })
1051                    .ok();
1052            }),
1053            is_agent_layout,
1054        });
1055
1056        // Subscribe to extension events to sync agent servers when extensions change
1057        let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
1058        {
1059            Some(
1060                cx.subscribe(&extension_events, |this, _source, event, cx| match event {
1061                    extension::Event::ExtensionInstalled(_)
1062                    | extension::Event::ExtensionUninstalled(_)
1063                    | extension::Event::ExtensionsInstalledChanged => {
1064                        this.sync_agent_servers_from_extensions(cx);
1065                    }
1066                    _ => {}
1067                }),
1068            )
1069        } else {
1070            None
1071        };
1072
1073        let connection_store = cx.new(|cx| {
1074            let mut store = AgentConnectionStore::new(project.clone(), cx);
1075            // Register the native agent right away, so that it is available for
1076            // the inline assistant etc.
1077            store.request_connection(
1078                Agent::NativeAgent,
1079                Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
1080                cx,
1081            );
1082            store
1083        });
1084        let _project_subscription =
1085            cx.subscribe(&project, |this, _project, event, cx| match event {
1086                project::Event::WorktreeAdded(_)
1087                | project::Event::WorktreeRemoved(_)
1088                | project::Event::WorktreeOrderChanged => {
1089                    this.update_thread_work_dirs(cx);
1090                }
1091                _ => {}
1092            });
1093        let git_store = project.read(cx).git_store().clone();
1094        let _git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| {
1095            let should_sync = matches!(
1096                event,
1097                GitStoreEvent::ActiveRepositoryChanged(_)
1098                    | GitStoreEvent::RepositoryAdded
1099                    | GitStoreEvent::RepositoryRemoved(_)
1100                    | GitStoreEvent::RepositoryUpdated(
1101                        _,
1102                        RepositoryEvent::HeadChanged
1103                            | RepositoryEvent::BranchListChanged
1104                            | RepositoryEvent::GitWorktreeListChanged,
1105                        _,
1106                    )
1107            );
1108
1109            if should_sync {
1110                this.sync_start_thread_in_with_git_state(cx);
1111            }
1112        });
1113
1114        let mut panel = Self {
1115            workspace_id,
1116            active_view,
1117            workspace,
1118            user_store,
1119            project: project.clone(),
1120            fs: fs.clone(),
1121            language_registry,
1122            prompt_store,
1123            connection_store,
1124            configuration: None,
1125            configuration_subscription: None,
1126            focus_handle: cx.focus_handle(),
1127            context_server_registry,
1128            previous_view: None,
1129            background_threads: HashMap::default(),
1130            draft_threads: HashMap::default(),
1131            new_thread_menu_handle: PopoverMenuHandle::default(),
1132            start_thread_in_menu_handle: PopoverMenuHandle::default(),
1133            thread_branch_menu_handle: PopoverMenuHandle::default(),
1134            agent_panel_menu_handle: PopoverMenuHandle::default(),
1135            _extension_subscription: extension_subscription,
1136            _project_subscription,
1137            _git_store_subscription,
1138            zoomed: false,
1139            pending_serialization: None,
1140            new_user_onboarding: onboarding,
1141            agent_layout_onboarding,
1142            thread_store,
1143            selected_agent: Agent::default(),
1144            start_thread_in: StartThreadIn::default(),
1145            worktree_creation_status: None,
1146            _thread_view_subscription: None,
1147            _active_thread_focus_subscription: None,
1148            _worktree_creation_task: None,
1149            show_trust_workspace_message: false,
1150            new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
1151            agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed(
1152                cx,
1153            )),
1154            _active_view_observation: None,
1155        };
1156
1157        // Initial sync of agent servers from extensions
1158        panel.sync_agent_servers_from_extensions(cx);
1159        panel
1160    }
1161
1162    pub fn toggle_focus(
1163        workspace: &mut Workspace,
1164        _: &ToggleFocus,
1165        window: &mut Window,
1166        cx: &mut Context<Workspace>,
1167    ) {
1168        if workspace
1169            .panel::<Self>(cx)
1170            .is_some_and(|panel| panel.read(cx).enabled(cx))
1171        {
1172            workspace.toggle_panel_focus::<Self>(window, cx);
1173        }
1174    }
1175
1176    pub fn toggle(
1177        workspace: &mut Workspace,
1178        _: &Toggle,
1179        window: &mut Window,
1180        cx: &mut Context<Workspace>,
1181    ) {
1182        if workspace
1183            .panel::<Self>(cx)
1184            .is_some_and(|panel| panel.read(cx).enabled(cx))
1185        {
1186            if !workspace.toggle_panel_focus::<Self>(window, cx) {
1187                workspace.close_panel::<Self>(window, cx);
1188            }
1189        }
1190    }
1191
1192    pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
1193        &self.prompt_store
1194    }
1195
1196    pub fn thread_store(&self) -> &Entity<ThreadStore> {
1197        &self.thread_store
1198    }
1199
1200    pub fn connection_store(&self) -> &Entity<AgentConnectionStore> {
1201        &self.connection_store
1202    }
1203
1204    pub fn open_thread(
1205        &mut self,
1206        session_id: acp::SessionId,
1207        work_dirs: Option<PathList>,
1208        title: Option<SharedString>,
1209        window: &mut Window,
1210        cx: &mut Context<Self>,
1211    ) {
1212        self.external_thread(
1213            Some(crate::Agent::NativeAgent),
1214            Some(session_id),
1215            work_dirs,
1216            title,
1217            None,
1218            true,
1219            window,
1220            cx,
1221        );
1222    }
1223
1224    pub(crate) fn context_server_registry(&self) -> &Entity<ContextServerRegistry> {
1225        &self.context_server_registry
1226    }
1227
1228    pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
1229        let workspace_read = workspace.read(cx);
1230
1231        workspace_read
1232            .panel::<AgentPanel>(cx)
1233            .map(|panel| {
1234                let panel_id = Entity::entity_id(&panel);
1235
1236                workspace_read.all_docks().iter().any(|dock| {
1237                    dock.read(cx)
1238                        .visible_panel()
1239                        .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
1240                })
1241            })
1242            .unwrap_or(false)
1243    }
1244
1245    /// Reset the panel to the uninitialized state, clearing any active
1246    /// thread without creating a new draft. Running threads are retained
1247    /// in the background. The sidebar suppresses the uninitialized state
1248    /// so no "Draft" entry appears.
1249    pub fn clear_active_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1250        self.set_active_view(ActiveView::Uninitialized, false, window, cx);
1251    }
1252
1253    pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
1254        let id = self.create_draft(window, cx);
1255        self.activate_draft(id, true, window, cx);
1256    }
1257
1258    /// Creates a new empty draft thread and stores it. Returns the DraftId.
1259    /// The draft is NOT activated — call `activate_draft` to show it.
1260    pub fn create_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) -> DraftId {
1261        let id = DraftId::next(cx);
1262        let workspace = self.workspace.clone();
1263        let project = self.project.clone();
1264        let fs = self.fs.clone();
1265        let thread_store = self.thread_store.clone();
1266        let agent = if self.project.read(cx).is_via_collab() {
1267            Agent::NativeAgent
1268        } else {
1269            self.selected_agent.clone()
1270        };
1271        let server = agent.server(fs, thread_store);
1272        let conversation_view = self.create_agent_thread(
1273            server, None, None, None, None, workspace, project, agent, window, cx,
1274        );
1275        self.draft_threads.insert(id, conversation_view);
1276        id
1277    }
1278
1279    pub fn activate_draft(
1280        &mut self,
1281        id: DraftId,
1282        focus: bool,
1283        window: &mut Window,
1284        cx: &mut Context<Self>,
1285    ) {
1286        let Some(conversation_view) = self.draft_threads.get(&id).cloned() else {
1287            return;
1288        };
1289        self.set_active_view(
1290            ActiveView::AgentThread { conversation_view },
1291            focus,
1292            window,
1293            cx,
1294        );
1295    }
1296
1297    /// Removes a draft thread. If it's currently active, does nothing to
1298    /// the active view — the caller should activate something else first.
1299    pub fn remove_draft(&mut self, id: DraftId) {
1300        self.draft_threads.remove(&id);
1301    }
1302
1303    /// Returns the DraftId of the currently active draft, if the active
1304    /// view is a draft thread tracked in `draft_threads`.
1305    pub fn active_draft_id(&self) -> Option<DraftId> {
1306        let active_cv = self.active_conversation_view()?;
1307        self.draft_threads
1308            .iter()
1309            .find_map(|(id, cv)| (cv.entity_id() == active_cv.entity_id()).then_some(*id))
1310    }
1311
1312    /// Returns all draft IDs, sorted newest-first.
1313    pub fn draft_ids(&self) -> Vec<DraftId> {
1314        let mut ids: Vec<DraftId> = self.draft_threads.keys().copied().collect();
1315        ids.sort_by_key(|id| std::cmp::Reverse(id.0));
1316        ids
1317    }
1318
1319    /// Returns the text from a draft's message editor, or `None` if the
1320    /// draft doesn't exist or has no text.
1321    pub fn draft_editor_text(&self, id: DraftId, cx: &App) -> Option<String> {
1322        let cv = self.draft_threads.get(&id)?;
1323        let tv = cv.read(cx).active_thread()?;
1324        let text = tv.read(cx).message_editor.read(cx).text(cx);
1325        if text.trim().is_empty() {
1326            None
1327        } else {
1328            Some(text)
1329        }
1330    }
1331
1332    /// Clears the message editor text of a tracked draft.
1333    pub fn clear_draft_editor(&self, id: DraftId, window: &mut Window, cx: &mut Context<Self>) {
1334        let Some(cv) = self.draft_threads.get(&id) else {
1335            return;
1336        };
1337        let Some(tv) = cv.read(cx).active_thread() else {
1338            return;
1339        };
1340        let editor = tv.read(cx).message_editor.clone();
1341        editor.update(cx, |editor, cx| {
1342            editor.clear(window, cx);
1343        });
1344    }
1345
1346    fn take_active_draft_initial_content(
1347        &mut self,
1348        cx: &mut Context<Self>,
1349    ) -> Option<AgentInitialContent> {
1350        self.active_thread_view(cx).and_then(|thread_view| {
1351            thread_view.update(cx, |thread_view, cx| {
1352                let draft_blocks = thread_view
1353                    .thread
1354                    .read(cx)
1355                    .draft_prompt()
1356                    .map(|draft| draft.to_vec())
1357                    .filter(|draft| !draft.is_empty());
1358
1359                let draft_blocks = draft_blocks.or_else(|| {
1360                    let text = thread_view.message_editor.read(cx).text(cx);
1361                    if text.trim().is_empty() {
1362                        None
1363                    } else {
1364                        Some(vec![acp::ContentBlock::Text(acp::TextContent::new(text))])
1365                    }
1366                });
1367
1368                draft_blocks.map(|blocks| AgentInitialContent::ContentBlock {
1369                    blocks,
1370                    auto_submit: false,
1371                })
1372            })
1373        })
1374    }
1375
1376    fn new_native_agent_thread_from_summary(
1377        &mut self,
1378        action: &NewNativeAgentThreadFromSummary,
1379        window: &mut Window,
1380        cx: &mut Context<Self>,
1381    ) {
1382        let session_id = action.from_session_id.clone();
1383
1384        let Some(thread) = ThreadStore::global(cx)
1385            .read(cx)
1386            .entries()
1387            .find(|t| t.id == session_id)
1388        else {
1389            log::error!("No session found for summarization with id {}", session_id);
1390            return;
1391        };
1392
1393        self.external_thread(
1394            Some(Agent::NativeAgent),
1395            None,
1396            None,
1397            None,
1398            Some(AgentInitialContent::ThreadSummary {
1399                session_id: thread.id,
1400                title: Some(thread.title),
1401            }),
1402            true,
1403            window,
1404            cx,
1405        );
1406    }
1407
1408    fn external_thread(
1409        &mut self,
1410        agent_choice: Option<crate::Agent>,
1411        resume_session_id: Option<acp::SessionId>,
1412        work_dirs: Option<PathList>,
1413        title: Option<SharedString>,
1414        initial_content: Option<AgentInitialContent>,
1415        focus: bool,
1416        window: &mut Window,
1417        cx: &mut Context<Self>,
1418    ) {
1419        let workspace = self.workspace.clone();
1420        let project = self.project.clone();
1421        let fs = self.fs.clone();
1422        let thread_store = self.thread_store.clone();
1423
1424        let agent = agent_choice.unwrap_or_else(|| {
1425            if self.project.read(cx).is_via_collab() {
1426                Agent::NativeAgent
1427            } else {
1428                self.selected_agent.clone()
1429            }
1430        });
1431
1432        let server = agent.server(fs, thread_store);
1433        let conversation_view = self.create_agent_thread(
1434            server,
1435            resume_session_id,
1436            work_dirs,
1437            title,
1438            initial_content,
1439            workspace,
1440            project,
1441            agent,
1442            window,
1443            cx,
1444        );
1445        self.set_active_view(
1446            ActiveView::AgentThread { conversation_view },
1447            focus,
1448            window,
1449            cx,
1450        );
1451    }
1452
1453    fn deploy_rules_library(
1454        &mut self,
1455        action: &OpenRulesLibrary,
1456        _window: &mut Window,
1457        cx: &mut Context<Self>,
1458    ) {
1459        open_rules_library(
1460            self.language_registry.clone(),
1461            Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
1462            action
1463                .prompt_to_select
1464                .map(|uuid| UserPromptId(uuid).into()),
1465            cx,
1466        )
1467        .detach_and_log_err(cx);
1468    }
1469
1470    fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1471        let Some(conversation_view) = self.active_conversation_view() else {
1472            return;
1473        };
1474
1475        let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() else {
1476            return;
1477        };
1478
1479        active_thread.update(cx, |active_thread, cx| {
1480            active_thread.expand_message_editor(&ExpandMessageEditor, window, cx);
1481            active_thread.focus_handle(cx).focus(window, cx);
1482        })
1483    }
1484
1485    pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
1486        if let ActiveView::Configuration = self.active_view {
1487            if let Some(previous_view) = self.previous_view.take() {
1488                self.set_active_view(previous_view, true, window, cx);
1489            }
1490            cx.notify();
1491        }
1492    }
1493
1494    pub fn toggle_options_menu(
1495        &mut self,
1496        _: &ToggleOptionsMenu,
1497        window: &mut Window,
1498        cx: &mut Context<Self>,
1499    ) {
1500        self.agent_panel_menu_handle.toggle(window, cx);
1501    }
1502
1503    pub fn toggle_new_thread_menu(
1504        &mut self,
1505        _: &ToggleNewThreadMenu,
1506        window: &mut Window,
1507        cx: &mut Context<Self>,
1508    ) {
1509        self.new_thread_menu_handle.toggle(window, cx);
1510    }
1511
1512    pub fn increase_font_size(
1513        &mut self,
1514        action: &IncreaseBufferFontSize,
1515        _: &mut Window,
1516        cx: &mut Context<Self>,
1517    ) {
1518        self.handle_font_size_action(action.persist, px(1.0), cx);
1519    }
1520
1521    pub fn decrease_font_size(
1522        &mut self,
1523        action: &DecreaseBufferFontSize,
1524        _: &mut Window,
1525        cx: &mut Context<Self>,
1526    ) {
1527        self.handle_font_size_action(action.persist, px(-1.0), cx);
1528    }
1529
1530    fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
1531        match self.active_view.which_font_size_used() {
1532            WhichFontSize::AgentFont => {
1533                if persist {
1534                    update_settings_file(self.fs.clone(), cx, move |settings, cx| {
1535                        let agent_ui_font_size =
1536                            ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta;
1537                        let agent_buffer_font_size =
1538                            ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta;
1539
1540                        let _ = settings.theme.agent_ui_font_size.insert(
1541                            f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(),
1542                        );
1543                        let _ = settings.theme.agent_buffer_font_size.insert(
1544                            f32::from(theme_settings::clamp_font_size(agent_buffer_font_size))
1545                                .into(),
1546                        );
1547                    });
1548                } else {
1549                    theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta);
1550                    theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta);
1551                }
1552            }
1553            WhichFontSize::None => {}
1554        }
1555    }
1556
1557    pub fn reset_font_size(
1558        &mut self,
1559        action: &ResetBufferFontSize,
1560        _: &mut Window,
1561        cx: &mut Context<Self>,
1562    ) {
1563        if action.persist {
1564            update_settings_file(self.fs.clone(), cx, move |settings, _| {
1565                settings.theme.agent_ui_font_size = None;
1566                settings.theme.agent_buffer_font_size = None;
1567            });
1568        } else {
1569            theme_settings::reset_agent_ui_font_size(cx);
1570            theme_settings::reset_agent_buffer_font_size(cx);
1571        }
1572    }
1573
1574    pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1575        theme_settings::reset_agent_ui_font_size(cx);
1576        theme_settings::reset_agent_buffer_font_size(cx);
1577    }
1578
1579    pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
1580        if self.zoomed {
1581            cx.emit(PanelEvent::ZoomOut);
1582        } else {
1583            if !self.focus_handle(cx).contains_focused(window, cx) {
1584                cx.focus_self(window);
1585            }
1586            cx.emit(PanelEvent::ZoomIn);
1587        }
1588    }
1589
1590    pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1591        let agent_server_store = self.project.read(cx).agent_server_store().clone();
1592        let context_server_store = self.project.read(cx).context_server_store();
1593        let fs = self.fs.clone();
1594
1595        self.set_active_view(ActiveView::Configuration, true, window, cx);
1596        self.configuration = Some(cx.new(|cx| {
1597            AgentConfiguration::new(
1598                fs,
1599                agent_server_store,
1600                self.connection_store.clone(),
1601                context_server_store,
1602                self.context_server_registry.clone(),
1603                self.language_registry.clone(),
1604                self.workspace.clone(),
1605                window,
1606                cx,
1607            )
1608        }));
1609
1610        if let Some(configuration) = self.configuration.as_ref() {
1611            self.configuration_subscription = Some(cx.subscribe_in(
1612                configuration,
1613                window,
1614                Self::handle_agent_configuration_event,
1615            ));
1616
1617            configuration.focus_handle(cx).focus(window, cx);
1618        }
1619    }
1620
1621    pub(crate) fn open_active_thread_as_markdown(
1622        &mut self,
1623        _: &OpenActiveThreadAsMarkdown,
1624        window: &mut Window,
1625        cx: &mut Context<Self>,
1626    ) {
1627        if let Some(workspace) = self.workspace.upgrade()
1628            && let Some(conversation_view) = self.active_conversation_view()
1629            && let Some(active_thread) = conversation_view.read(cx).active_thread().cloned()
1630        {
1631            active_thread.update(cx, |thread, cx| {
1632                thread
1633                    .open_thread_as_markdown(workspace, window, cx)
1634                    .detach_and_log_err(cx);
1635            });
1636        }
1637    }
1638
1639    fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1640        let Some(thread) = self.active_native_agent_thread(cx) else {
1641            Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx);
1642            return;
1643        };
1644
1645        let workspace = self.workspace.clone();
1646        let load_task = thread.read(cx).to_db(cx);
1647
1648        cx.spawn_in(window, async move |_this, cx| {
1649            let db_thread = load_task.await;
1650            let shared_thread = SharedThread::from_db_thread(&db_thread);
1651            let thread_data = shared_thread.to_bytes()?;
1652            let encoded = base64::Engine::encode(&base64::prelude::BASE64_STANDARD, &thread_data);
1653
1654            cx.update(|_window, cx| {
1655                cx.write_to_clipboard(ClipboardItem::new_string(encoded));
1656                if let Some(workspace) = workspace.upgrade() {
1657                    workspace.update(cx, |workspace, cx| {
1658                        struct ThreadCopiedToast;
1659                        workspace.show_toast(
1660                            workspace::Toast::new(
1661                                workspace::notifications::NotificationId::unique::<ThreadCopiedToast>(),
1662                                "Thread copied to clipboard (base64 encoded)",
1663                            )
1664                            .autohide(),
1665                            cx,
1666                        );
1667                    });
1668                }
1669            })?;
1670
1671            anyhow::Ok(())
1672        })
1673        .detach_and_log_err(cx);
1674    }
1675
1676    fn show_deferred_toast(
1677        workspace: &WeakEntity<workspace::Workspace>,
1678        message: &'static str,
1679        cx: &mut App,
1680    ) {
1681        let workspace = workspace.clone();
1682        cx.defer(move |cx| {
1683            if let Some(workspace) = workspace.upgrade() {
1684                workspace.update(cx, |workspace, cx| {
1685                    struct ClipboardToast;
1686                    workspace.show_toast(
1687                        workspace::Toast::new(
1688                            workspace::notifications::NotificationId::unique::<ClipboardToast>(),
1689                            message,
1690                        )
1691                        .autohide(),
1692                        cx,
1693                    );
1694                });
1695            }
1696        });
1697    }
1698
1699    fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1700        let Some(clipboard) = cx.read_from_clipboard() else {
1701            Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
1702            return;
1703        };
1704
1705        let Some(encoded) = clipboard.text() else {
1706            Self::show_deferred_toast(&self.workspace, "Clipboard does not contain text", cx);
1707            return;
1708        };
1709
1710        let thread_data = match base64::Engine::decode(&base64::prelude::BASE64_STANDARD, &encoded)
1711        {
1712            Ok(data) => data,
1713            Err(_) => {
1714                Self::show_deferred_toast(
1715                    &self.workspace,
1716                    "Failed to decode clipboard content (expected base64)",
1717                    cx,
1718                );
1719                return;
1720            }
1721        };
1722
1723        let shared_thread = match SharedThread::from_bytes(&thread_data) {
1724            Ok(thread) => thread,
1725            Err(_) => {
1726                Self::show_deferred_toast(
1727                    &self.workspace,
1728                    "Failed to parse thread data from clipboard",
1729                    cx,
1730                );
1731                return;
1732            }
1733        };
1734
1735        let db_thread = shared_thread.to_db_thread();
1736        let session_id = acp::SessionId::new(uuid::Uuid::new_v4().to_string());
1737        let thread_store = self.thread_store.clone();
1738        let title = db_thread.title.clone();
1739        let workspace = self.workspace.clone();
1740
1741        cx.spawn_in(window, async move |this, cx| {
1742            thread_store
1743                .update(&mut cx.clone(), |store, cx| {
1744                    store.save_thread(session_id.clone(), db_thread, Default::default(), cx)
1745                })
1746                .await?;
1747
1748            this.update_in(cx, |this, window, cx| {
1749                this.open_thread(session_id, None, Some(title), window, cx);
1750            })?;
1751
1752            this.update_in(cx, |_, _window, cx| {
1753                if let Some(workspace) = workspace.upgrade() {
1754                    workspace.update(cx, |workspace, cx| {
1755                        struct ThreadLoadedToast;
1756                        workspace.show_toast(
1757                            workspace::Toast::new(
1758                                workspace::notifications::NotificationId::unique::<ThreadLoadedToast>(),
1759                                "Thread loaded from clipboard",
1760                            )
1761                            .autohide(),
1762                            cx,
1763                        );
1764                    });
1765                }
1766            })?;
1767
1768            anyhow::Ok(())
1769        })
1770        .detach_and_log_err(cx);
1771    }
1772
1773    fn handle_agent_configuration_event(
1774        &mut self,
1775        _entity: &Entity<AgentConfiguration>,
1776        event: &AssistantConfigurationEvent,
1777        window: &mut Window,
1778        cx: &mut Context<Self>,
1779    ) {
1780        match event {
1781            AssistantConfigurationEvent::NewThread(provider) => {
1782                if LanguageModelRegistry::read_global(cx)
1783                    .default_model()
1784                    .is_none_or(|model| model.provider.id() != provider.id())
1785                    && let Some(model) = provider.default_model(cx)
1786                {
1787                    update_settings_file(self.fs.clone(), cx, move |settings, _| {
1788                        let provider = model.provider_id().0.to_string();
1789                        let enable_thinking = model.supports_thinking();
1790                        let effort = model
1791                            .default_effort_level()
1792                            .map(|effort| effort.value.to_string());
1793                        let model = model.id().0.to_string();
1794                        settings
1795                            .agent
1796                            .get_or_insert_default()
1797                            .set_model(LanguageModelSelection {
1798                                provider: LanguageModelProviderSetting(provider),
1799                                model,
1800                                enable_thinking,
1801                                effort,
1802                                speed: None,
1803                            })
1804                    });
1805                }
1806
1807                self.new_thread(&NewThread, window, cx);
1808                if let Some((thread, model)) = self
1809                    .active_native_agent_thread(cx)
1810                    .zip(provider.default_model(cx))
1811                {
1812                    thread.update(cx, |thread, cx| {
1813                        thread.set_model(model, cx);
1814                    });
1815                }
1816            }
1817        }
1818    }
1819
1820    pub fn workspace_id(&self) -> Option<WorkspaceId> {
1821        self.workspace_id
1822    }
1823
1824    pub fn background_threads(&self) -> &HashMap<acp::SessionId, Entity<ConversationView>> {
1825        &self.background_threads
1826    }
1827
1828    pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
1829        match &self.active_view {
1830            ActiveView::AgentThread { conversation_view } => Some(conversation_view),
1831            _ => None,
1832        }
1833    }
1834
1835    pub fn conversation_views(&self) -> Vec<Entity<ConversationView>> {
1836        self.active_conversation_view()
1837            .into_iter()
1838            .cloned()
1839            .chain(self.background_threads.values().cloned())
1840            .collect()
1841    }
1842
1843    pub fn active_thread_view(&self, cx: &App) -> Option<Entity<ThreadView>> {
1844        let server_view = self.active_conversation_view()?;
1845        server_view.read(cx).active_thread().cloned()
1846    }
1847
1848    pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
1849        match &self.active_view {
1850            ActiveView::AgentThread {
1851                conversation_view, ..
1852            } => conversation_view
1853                .read(cx)
1854                .active_thread()
1855                .map(|r| r.read(cx).thread.clone()),
1856            _ => None,
1857        }
1858    }
1859
1860    /// Returns the primary thread views for all retained connections: the
1861    pub fn is_background_thread(&self, session_id: &acp::SessionId) -> bool {
1862        self.background_threads.contains_key(session_id)
1863    }
1864
1865    pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
1866        let conversation_views = self
1867            .active_conversation_view()
1868            .into_iter()
1869            .chain(self.background_threads.values());
1870
1871        for conversation_view in conversation_views {
1872            if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
1873                thread_view.update(cx, |view, cx| view.cancel_generation(cx));
1874                return true;
1875            }
1876        }
1877        false
1878    }
1879
1880    /// active thread plus any background threads that are still running or
1881    /// completed but unseen.
1882    pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
1883        let mut views = Vec::new();
1884
1885        if let Some(server_view) = self.active_conversation_view() {
1886            if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1887                views.push(thread_view);
1888            }
1889        }
1890
1891        for server_view in self.background_threads.values() {
1892            if let Some(thread_view) = server_view.read(cx).root_thread(cx) {
1893                views.push(thread_view);
1894            }
1895        }
1896
1897        views
1898    }
1899
1900    fn update_thread_work_dirs(&self, cx: &mut Context<Self>) {
1901        let new_work_dirs = self.project.read(cx).default_path_list(cx);
1902
1903        if let Some(conversation_view) = self.active_conversation_view() {
1904            conversation_view.update(cx, |conversation_view, cx| {
1905                conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
1906            });
1907        }
1908
1909        for conversation_view in self.background_threads.values() {
1910            conversation_view.update(cx, |conversation_view, cx| {
1911                conversation_view.set_work_dirs(new_work_dirs.clone(), cx);
1912            });
1913        }
1914    }
1915
1916    fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context<Self>) {
1917        let ActiveView::AgentThread { conversation_view } = old_view else {
1918            return;
1919        };
1920
1921        // If this ConversationView is a tracked draft, it's already
1922        // stored in `draft_threads` — don't drop it.
1923        let is_tracked_draft = self
1924            .draft_threads
1925            .values()
1926            .any(|cv| cv.entity_id() == conversation_view.entity_id());
1927        if is_tracked_draft {
1928            return;
1929        }
1930
1931        let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
1932            return;
1933        };
1934
1935        if thread_view.read(cx).thread.read(cx).entries().is_empty() {
1936            return;
1937        }
1938
1939        self.background_threads
1940            .insert(thread_view.read(cx).id.clone(), conversation_view);
1941        self.cleanup_background_threads(cx);
1942    }
1943
1944    /// We keep threads that are:
1945    /// - Still running
1946    /// - Do not support reloading the full session
1947    /// - Have had the most recent events (up to 5 idle threads)
1948    fn cleanup_background_threads(&mut self, cx: &App) {
1949        let mut potential_removals = self
1950            .background_threads
1951            .iter()
1952            .filter(|(_id, view)| {
1953                let Some(thread_view) = view.read(cx).root_thread(cx) else {
1954                    return true;
1955                };
1956                let thread = thread_view.read(cx).thread.read(cx);
1957                thread.connection().supports_load_session() && thread.status() == ThreadStatus::Idle
1958            })
1959            .collect::<Vec<_>>();
1960
1961        const MAX_IDLE_BACKGROUND_THREADS: usize = 5;
1962
1963        potential_removals.sort_unstable_by_key(|(_, view)| view.read(cx).updated_at(cx));
1964        let n = potential_removals
1965            .len()
1966            .saturating_sub(MAX_IDLE_BACKGROUND_THREADS);
1967        let to_remove = potential_removals
1968            .into_iter()
1969            .map(|(id, _)| id.clone())
1970            .take(n)
1971            .collect::<Vec<_>>();
1972        for id in to_remove {
1973            self.background_threads.remove(&id);
1974        }
1975    }
1976
1977    pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
1978        match &self.active_view {
1979            ActiveView::AgentThread {
1980                conversation_view, ..
1981            } => conversation_view.read(cx).as_native_thread(cx),
1982            _ => None,
1983        }
1984    }
1985
1986    fn set_active_view(
1987        &mut self,
1988        new_view: ActiveView,
1989        focus: bool,
1990        window: &mut Window,
1991        cx: &mut Context<Self>,
1992    ) {
1993        let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized);
1994        let current_is_config = matches!(self.active_view, ActiveView::Configuration);
1995        let new_is_config = matches!(new_view, ActiveView::Configuration);
1996
1997        let current_is_overlay = current_is_config;
1998        let new_is_overlay = new_is_config;
1999
2000        if current_is_uninitialized || (current_is_overlay && !new_is_overlay) {
2001            self.active_view = new_view;
2002        } else if !current_is_overlay && new_is_overlay {
2003            self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
2004        } else {
2005            let old_view = std::mem::replace(&mut self.active_view, new_view);
2006            if !new_is_overlay {
2007                if let Some(previous) = self.previous_view.take() {
2008                    self.retain_running_thread(previous, cx);
2009                }
2010            }
2011            self.retain_running_thread(old_view, cx);
2012        }
2013
2014        // Keep the toolbar's selected agent in sync with the active thread's agent.
2015        if let ActiveView::AgentThread { conversation_view } = &self.active_view {
2016            let thread_agent = conversation_view.read(cx).agent_key().clone();
2017            if self.selected_agent != thread_agent {
2018                self.selected_agent = thread_agent;
2019                self.serialize(cx);
2020            }
2021        }
2022
2023        // Subscribe to the active ThreadView's events (e.g. FirstSendRequested)
2024        // so the panel can intercept the first send for worktree creation.
2025        // Re-subscribe whenever the ConnectionView changes, since the inner
2026        // ThreadView may have been replaced (e.g. navigating between threads).
2027        self._active_view_observation = match &self.active_view {
2028            ActiveView::AgentThread { conversation_view } => {
2029                self._thread_view_subscription =
2030                    Self::subscribe_to_active_thread_view(conversation_view, window, cx);
2031                let focus_handle = conversation_view.focus_handle(cx);
2032                self._active_thread_focus_subscription =
2033                    Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| {
2034                        cx.emit(AgentPanelEvent::ThreadFocused);
2035                        cx.notify();
2036                    }));
2037                Some(cx.observe_in(
2038                    conversation_view,
2039                    window,
2040                    |this, server_view, window, cx| {
2041                        this._thread_view_subscription =
2042                            Self::subscribe_to_active_thread_view(&server_view, window, cx);
2043                        cx.emit(AgentPanelEvent::ActiveViewChanged);
2044                        this.serialize(cx);
2045                        cx.notify();
2046                    },
2047                ))
2048            }
2049            _ => {
2050                self._thread_view_subscription = None;
2051                self._active_thread_focus_subscription = None;
2052                None
2053            }
2054        };
2055
2056        if focus {
2057            self.focus_handle(cx).focus(window, cx);
2058        }
2059        cx.emit(AgentPanelEvent::ActiveViewChanged);
2060    }
2061
2062    fn subscribe_to_active_thread_view(
2063        server_view: &Entity<ConversationView>,
2064        window: &mut Window,
2065        cx: &mut Context<Self>,
2066    ) -> Option<Subscription> {
2067        server_view.read(cx).active_thread().cloned().map(|tv| {
2068            cx.subscribe_in(
2069                &tv,
2070                window,
2071                |this, view, event: &AcpThreadViewEvent, window, cx| match event {
2072                    AcpThreadViewEvent::FirstSendRequested { content } => {
2073                        this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
2074                    }
2075                    AcpThreadViewEvent::MessageSentOrQueued => {
2076                        // When a draft sends its first message it becomes a
2077                        // real thread. Remove it from `draft_threads` so the
2078                        // sidebar stops showing a stale draft entry.
2079                        if let Some(draft_id) = this.active_draft_id() {
2080                            this.draft_threads.remove(&draft_id);
2081                        }
2082                        let session_id = view.read(cx).thread.read(cx).session_id().clone();
2083                        cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id });
2084                    }
2085                },
2086            )
2087        })
2088    }
2089
2090    pub fn start_thread_in(&self) -> &StartThreadIn {
2091        &self.start_thread_in
2092    }
2093
2094    fn set_start_thread_in(
2095        &mut self,
2096        action: &StartThreadIn,
2097        window: &mut Window,
2098        cx: &mut Context<Self>,
2099    ) {
2100        let new_target = match action {
2101            StartThreadIn::LocalProject => StartThreadIn::LocalProject,
2102            StartThreadIn::NewWorktree { .. } => {
2103                if !self.project_has_git_repository(cx) {
2104                    log::error!(
2105                        "set_start_thread_in: cannot use worktree mode without a git repository"
2106                    );
2107                    return;
2108                }
2109                if self.project.read(cx).is_via_collab() {
2110                    log::error!(
2111                        "set_start_thread_in: cannot use worktree mode in a collab project"
2112                    );
2113                    return;
2114                }
2115                action.clone()
2116            }
2117            StartThreadIn::LinkedWorktree { .. } => {
2118                if !self.project_has_git_repository(cx) {
2119                    log::error!(
2120                        "set_start_thread_in: cannot use LinkedWorktree without a git repository"
2121                    );
2122                    return;
2123                }
2124                if self.project.read(cx).is_via_collab() {
2125                    log::error!(
2126                        "set_start_thread_in: cannot use LinkedWorktree in a collab project"
2127                    );
2128                    return;
2129                }
2130                action.clone()
2131            }
2132        };
2133        self.start_thread_in = new_target;
2134        if let Some(thread) = self.active_thread_view(cx) {
2135            thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
2136        }
2137        self.serialize(cx);
2138        cx.notify();
2139    }
2140
2141    fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2142        let next = match &self.start_thread_in {
2143            StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
2144                worktree_name: None,
2145                branch_target: NewWorktreeBranchTarget::default(),
2146            },
2147            StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
2148                StartThreadIn::LocalProject
2149            }
2150        };
2151        self.set_start_thread_in(&next, window, cx);
2152    }
2153
2154    fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
2155        use settings::{NewThreadLocation, Settings};
2156        let default = AgentSettings::get_global(cx).new_thread_location;
2157        let start_thread_in = match default {
2158            NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
2159            NewThreadLocation::NewWorktree => {
2160                if self.project_has_git_repository(cx) {
2161                    StartThreadIn::NewWorktree {
2162                        worktree_name: None,
2163                        branch_target: NewWorktreeBranchTarget::default(),
2164                    }
2165                } else {
2166                    StartThreadIn::LocalProject
2167                }
2168            }
2169        };
2170        if self.start_thread_in != start_thread_in {
2171            self.start_thread_in = start_thread_in;
2172            self.serialize(cx);
2173            cx.notify();
2174        }
2175    }
2176
2177    fn sync_start_thread_in_with_git_state(&mut self, cx: &mut Context<Self>) {
2178        if matches!(self.start_thread_in, StartThreadIn::LocalProject) {
2179            return;
2180        }
2181
2182        let visible_worktree_paths: Vec<_> = self
2183            .project
2184            .read(cx)
2185            .visible_worktrees(cx)
2186            .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
2187            .collect();
2188        let repositories = self.project.read(cx).repositories(cx);
2189        let linked_worktrees = if repositories.len() > 1 {
2190            Vec::new()
2191        } else {
2192            repositories
2193                .values()
2194                .flat_map(|repo| repo.read(cx).linked_worktrees().iter().cloned())
2195                .filter(|worktree| !visible_worktree_paths.contains(&worktree.path))
2196                .collect::<Vec<_>>()
2197        };
2198
2199        let updated_start_thread_in = match &self.start_thread_in {
2200            StartThreadIn::NewWorktree {
2201                worktree_name: Some(worktree_name),
2202                branch_target,
2203            } => {
2204                let normalized_worktree_name = worktree_name.replace(' ', "-");
2205                linked_worktrees
2206                    .iter()
2207                    .find(|worktree| {
2208                        worktree.display_name() == normalized_worktree_name
2209                            && self.linked_worktree_matches_branch_target(
2210                                worktree,
2211                                branch_target,
2212                                cx,
2213                            )
2214                    })
2215                    .map(|worktree| StartThreadIn::LinkedWorktree {
2216                        path: worktree.path.clone(),
2217                        display_name: worktree.display_name().to_string(),
2218                    })
2219            }
2220            StartThreadIn::LinkedWorktree { path, .. } => linked_worktrees
2221                .iter()
2222                .find(|worktree| worktree.path == *path)
2223                .map(|worktree| StartThreadIn::LinkedWorktree {
2224                    path: worktree.path.clone(),
2225                    display_name: worktree.display_name().to_string(),
2226                })
2227                .or(Some(StartThreadIn::LocalProject)),
2228            _ => None,
2229        };
2230
2231        if let Some(updated_start_thread_in) = updated_start_thread_in {
2232            if self.start_thread_in != updated_start_thread_in {
2233                self.start_thread_in = updated_start_thread_in;
2234                self.serialize(cx);
2235            }
2236            cx.notify();
2237        }
2238    }
2239
2240    fn linked_worktree_matches_branch_target(
2241        &self,
2242        worktree: &git::repository::Worktree,
2243        branch_target: &NewWorktreeBranchTarget,
2244        cx: &App,
2245    ) -> bool {
2246        let active_repository = self.project.read(cx).active_repository(cx);
2247        let current_branch_name = active_repository.as_ref().and_then(|repo| {
2248            repo.read(cx)
2249                .branch
2250                .as_ref()
2251                .map(|branch| branch.name().to_string())
2252        });
2253        let existing_branch_names = active_repository
2254            .as_ref()
2255            .map(|repo| {
2256                repo.read(cx)
2257                    .branch_list
2258                    .iter()
2259                    .map(|branch| branch.name().to_string())
2260                    .collect::<HashSet<_>>()
2261            })
2262            .unwrap_or_default();
2263
2264        match branch_target {
2265            NewWorktreeBranchTarget::CurrentBranch => {
2266                current_branch_name.as_deref() == worktree.branch_name()
2267            }
2268            NewWorktreeBranchTarget::ExistingBranch { name } => {
2269                existing_branch_names.contains(name)
2270                    && worktree.branch_name() == Some(name.as_str())
2271            }
2272            NewWorktreeBranchTarget::CreateBranch { name, .. } => {
2273                !existing_branch_names.contains(name)
2274                    && worktree.branch_name() == Some(name.as_str())
2275            }
2276        }
2277    }
2278
2279    pub(crate) fn selected_agent(&self) -> Option<Agent> {
2280        Some(self.selected_agent.clone())
2281    }
2282
2283    fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
2284        if let Some(extension_store) = ExtensionStore::try_global(cx) {
2285            let (manifests, extensions_dir) = {
2286                let store = extension_store.read(cx);
2287                let installed = store.installed_extensions();
2288                let manifests: Vec<_> = installed
2289                    .iter()
2290                    .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
2291                    .collect();
2292                let extensions_dir = paths::extensions_dir().join("installed");
2293                (manifests, extensions_dir)
2294            };
2295
2296            self.project.update(cx, |project, cx| {
2297                project.agent_server_store().update(cx, |store, cx| {
2298                    let manifest_refs: Vec<_> = manifests
2299                        .iter()
2300                        .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
2301                        .collect();
2302                    store.sync_extension_agents(manifest_refs, extensions_dir, cx);
2303                });
2304            });
2305        }
2306    }
2307
2308    pub fn new_agent_thread_with_external_source_prompt(
2309        &mut self,
2310        external_source_prompt: Option<ExternalSourcePrompt>,
2311        window: &mut Window,
2312        cx: &mut Context<Self>,
2313    ) {
2314        self.external_thread(
2315            None,
2316            None,
2317            None,
2318            None,
2319            external_source_prompt.map(AgentInitialContent::from),
2320            true,
2321            window,
2322            cx,
2323        );
2324    }
2325
2326    pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
2327        self.reset_start_thread_in_to_default(cx);
2328        self.new_agent_thread_inner(agent, true, window, cx);
2329    }
2330
2331    fn new_agent_thread_inner(
2332        &mut self,
2333        agent: Agent,
2334        focus: bool,
2335        window: &mut Window,
2336        cx: &mut Context<Self>,
2337    ) {
2338        let initial_content = self.take_active_draft_initial_content(cx);
2339        self.external_thread(
2340            Some(agent),
2341            None,
2342            None,
2343            None,
2344            initial_content,
2345            focus,
2346            window,
2347            cx,
2348        );
2349    }
2350
2351    pub fn load_agent_thread(
2352        &mut self,
2353        agent: Agent,
2354        session_id: acp::SessionId,
2355        work_dirs: Option<PathList>,
2356        title: Option<SharedString>,
2357        focus: bool,
2358        window: &mut Window,
2359        cx: &mut Context<Self>,
2360    ) {
2361        if let Some(store) = ThreadMetadataStore::try_global(cx) {
2362            store.update(cx, |store, cx| store.unarchive(&session_id, cx));
2363        }
2364
2365        if let Some(conversation_view) = self.background_threads.remove(&session_id) {
2366            self.set_active_view(
2367                ActiveView::AgentThread { conversation_view },
2368                focus,
2369                window,
2370                cx,
2371            );
2372            return;
2373        }
2374
2375        if let ActiveView::AgentThread { conversation_view } = &self.active_view {
2376            if conversation_view
2377                .read(cx)
2378                .active_thread()
2379                .map(|t| t.read(cx).id.clone())
2380                == Some(session_id.clone())
2381            {
2382                cx.emit(AgentPanelEvent::ActiveViewChanged);
2383                return;
2384            }
2385        }
2386
2387        if let Some(ActiveView::AgentThread { conversation_view }) = &self.previous_view {
2388            if conversation_view
2389                .read(cx)
2390                .active_thread()
2391                .map(|t| t.read(cx).id.clone())
2392                == Some(session_id.clone())
2393            {
2394                let view = self.previous_view.take().unwrap();
2395                self.set_active_view(view, focus, window, cx);
2396                return;
2397            }
2398        }
2399
2400        self.external_thread(
2401            Some(agent),
2402            Some(session_id),
2403            work_dirs,
2404            title,
2405            None,
2406            focus,
2407            window,
2408            cx,
2409        );
2410    }
2411
2412    pub(crate) fn create_agent_thread(
2413        &mut self,
2414        server: Rc<dyn AgentServer>,
2415        resume_session_id: Option<acp::SessionId>,
2416        work_dirs: Option<PathList>,
2417        title: Option<SharedString>,
2418        initial_content: Option<AgentInitialContent>,
2419        workspace: WeakEntity<Workspace>,
2420        project: Entity<Project>,
2421        agent: Agent,
2422        window: &mut Window,
2423        cx: &mut Context<Self>,
2424    ) -> Entity<ConversationView> {
2425        if self.selected_agent != agent {
2426            self.selected_agent = agent.clone();
2427            self.serialize(cx);
2428        }
2429
2430        cx.background_spawn({
2431            let kvp = KeyValueStore::global(cx);
2432            let agent = agent.clone();
2433            async move {
2434                write_global_last_used_agent(kvp, agent).await;
2435            }
2436        })
2437        .detach();
2438
2439        let thread_store = server
2440            .clone()
2441            .downcast::<agent::NativeAgentServer>()
2442            .is_some()
2443            .then(|| self.thread_store.clone());
2444
2445        let connection_store = self.connection_store.clone();
2446
2447        let conversation_view = cx.new(|cx| {
2448            crate::ConversationView::new(
2449                server,
2450                connection_store,
2451                agent,
2452                resume_session_id,
2453                work_dirs,
2454                title,
2455                initial_content,
2456                workspace.clone(),
2457                project,
2458                thread_store,
2459                self.prompt_store.clone(),
2460                window,
2461                cx,
2462            )
2463        });
2464
2465        cx.observe(&conversation_view, |this, server_view, cx| {
2466            let is_active = this
2467                .active_conversation_view()
2468                .is_some_and(|active| active.entity_id() == server_view.entity_id());
2469            if is_active {
2470                cx.emit(AgentPanelEvent::ActiveViewChanged);
2471                this.serialize(cx);
2472            } else {
2473                cx.emit(AgentPanelEvent::BackgroundThreadChanged);
2474            }
2475            cx.notify();
2476        })
2477        .detach();
2478
2479        conversation_view
2480    }
2481
2482    fn active_thread_has_messages(&self, cx: &App) -> bool {
2483        self.active_agent_thread(cx)
2484            .is_some_and(|thread| !thread.read(cx).entries().is_empty())
2485    }
2486
2487    pub fn active_thread_is_draft(&self, cx: &App) -> bool {
2488        self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
2489    }
2490
2491    fn handle_first_send_requested(
2492        &mut self,
2493        thread_view: Entity<ThreadView>,
2494        content: Vec<acp::ContentBlock>,
2495        window: &mut Window,
2496        cx: &mut Context<Self>,
2497    ) {
2498        match &self.start_thread_in {
2499            StartThreadIn::NewWorktree {
2500                worktree_name,
2501                branch_target,
2502            } => {
2503                self.handle_worktree_requested(
2504                    content,
2505                    WorktreeCreationArgs::New {
2506                        worktree_name: worktree_name.clone(),
2507                        branch_target: branch_target.clone(),
2508                    },
2509                    window,
2510                    cx,
2511                );
2512            }
2513            StartThreadIn::LinkedWorktree { path, .. } => {
2514                self.handle_worktree_requested(
2515                    content,
2516                    WorktreeCreationArgs::Linked {
2517                        worktree_path: path.clone(),
2518                    },
2519                    window,
2520                    cx,
2521                );
2522            }
2523            StartThreadIn::LocalProject => {
2524                cx.defer_in(window, move |_this, window, cx| {
2525                    thread_view.update(cx, |thread_view, cx| {
2526                        let editor = thread_view.message_editor.clone();
2527                        thread_view.send_impl(editor, window, cx);
2528                    });
2529                });
2530            }
2531        }
2532    }
2533
2534    // TODO: The mapping from workspace root paths to git repositories needs a
2535    // unified approach across the codebase: this method, `sidebar::is_root_repo`,
2536    // thread persistence (which PathList is saved to the database), and thread
2537    // querying (which PathList is used to read threads back). All of these need
2538    // to agree on how repos are resolved for a given workspace, especially in
2539    // multi-root and nested-repo configurations.
2540    /// Partitions the project's visible worktrees into git-backed repositories
2541    /// and plain (non-git) paths. Git repos will have worktrees created for
2542    /// them; non-git paths are carried over to the new workspace as-is.
2543    ///
2544    /// When multiple worktrees map to the same repository, the most specific
2545    /// match wins (deepest work directory path), with a deterministic
2546    /// tie-break on entity id. Each repository appears at most once.
2547    fn classify_worktrees(
2548        &self,
2549        cx: &App,
2550    ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
2551        let project = &self.project;
2552        let repositories = project.read(cx).repositories(cx).clone();
2553        let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
2554        let mut non_git_paths: Vec<PathBuf> = Vec::new();
2555        let mut seen_repo_ids = std::collections::HashSet::new();
2556
2557        for worktree in project.read(cx).visible_worktrees(cx) {
2558            let wt_path = worktree.read(cx).abs_path();
2559
2560            let matching_repo = repositories
2561                .iter()
2562                .filter_map(|(id, repo)| {
2563                    let work_dir = repo.read(cx).work_directory_abs_path.clone();
2564                    if wt_path.starts_with(work_dir.as_ref()) {
2565                        Some((*id, repo.clone(), work_dir.as_ref().components().count()))
2566                    } else {
2567                        None
2568                    }
2569                })
2570                .max_by(
2571                    |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
2572                        left_depth
2573                            .cmp(right_depth)
2574                            .then_with(|| left_id.cmp(right_id))
2575                    },
2576                );
2577
2578            if let Some((id, repo, _)) = matching_repo {
2579                if seen_repo_ids.insert(id) {
2580                    git_repos.push(repo);
2581                }
2582            } else {
2583                non_git_paths.push(wt_path.to_path_buf());
2584            }
2585        }
2586
2587        (git_repos, non_git_paths)
2588    }
2589
2590    fn resolve_worktree_branch_target(
2591        branch_target: &NewWorktreeBranchTarget,
2592        existing_branches: &HashSet<String>,
2593        occupied_branches: &HashSet<String>,
2594    ) -> Result<(String, bool, Option<String>)> {
2595        let generate_branch_name = || -> Result<String> {
2596            let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
2597            let mut rng = rand::rng();
2598            crate::branch_names::generate_branch_name(&refs, &mut rng)
2599                .ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
2600        };
2601
2602        match branch_target {
2603            NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
2604                Ok((name.clone(), false, from_ref.clone()))
2605            }
2606            NewWorktreeBranchTarget::ExistingBranch { name } => {
2607                if occupied_branches.contains(name) {
2608                    Ok((generate_branch_name()?, false, Some(name.clone())))
2609                } else {
2610                    Ok((name.clone(), true, None))
2611                }
2612            }
2613            NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
2614        }
2615    }
2616
2617    /// Kicks off an async git-worktree creation for each repository. Returns:
2618    ///
2619    /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
2620    ///   receiver resolves once the git worktree command finishes.
2621    /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
2622    ///   later to remap open editor tabs into the new workspace.
2623    fn start_worktree_creations(
2624        git_repos: &[Entity<project::git_store::Repository>],
2625        worktree_name: Option<String>,
2626        branch_name: &str,
2627        use_existing_branch: bool,
2628        start_point: Option<String>,
2629        worktree_directory_setting: &str,
2630        cx: &mut Context<Self>,
2631    ) -> Result<(
2632        Vec<(
2633            Entity<project::git_store::Repository>,
2634            PathBuf,
2635            futures::channel::oneshot::Receiver<Result<()>>,
2636        )>,
2637        Vec<(PathBuf, PathBuf)>,
2638    )> {
2639        let mut creation_infos = Vec::new();
2640        let mut path_remapping = Vec::new();
2641
2642        let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
2643
2644        for repo in git_repos {
2645            let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
2646                let new_path =
2647                    repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
2648                let target = if use_existing_branch {
2649                    debug_assert!(
2650                        git_repos.len() == 1,
2651                        "use_existing_branch should only be true for a single repo"
2652                    );
2653                    git::repository::CreateWorktreeTarget::ExistingBranch {
2654                        branch_name: branch_name.to_string(),
2655                    }
2656                } else {
2657                    git::repository::CreateWorktreeTarget::NewBranch {
2658                        branch_name: branch_name.to_string(),
2659                        base_sha: start_point.clone(),
2660                    }
2661                };
2662                let receiver = repo.create_worktree(target, new_path.clone());
2663                let work_dir = repo.work_directory_abs_path.clone();
2664                anyhow::Ok((work_dir, new_path, receiver))
2665            })?;
2666            path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
2667            creation_infos.push((repo.clone(), new_path, receiver));
2668        }
2669
2670        Ok((creation_infos, path_remapping))
2671    }
2672
2673    /// Waits for every in-flight worktree creation to complete. If any
2674    /// creation fails, all successfully-created worktrees are rolled back
2675    /// (removed) so the project isn't left in a half-migrated state.
2676    async fn await_and_rollback_on_failure(
2677        creation_infos: Vec<(
2678            Entity<project::git_store::Repository>,
2679            PathBuf,
2680            futures::channel::oneshot::Receiver<Result<()>>,
2681        )>,
2682        fs: Arc<dyn Fs>,
2683        cx: &mut AsyncWindowContext,
2684    ) -> Result<Vec<PathBuf>> {
2685        let mut created_paths: Vec<PathBuf> = Vec::new();
2686        let mut repos_and_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
2687            Vec::new();
2688        let mut first_error: Option<anyhow::Error> = None;
2689
2690        for (repo, new_path, receiver) in creation_infos {
2691            repos_and_paths.push((repo.clone(), new_path.clone()));
2692            match receiver.await {
2693                Ok(Ok(())) => {
2694                    created_paths.push(new_path);
2695                }
2696                Ok(Err(err)) => {
2697                    if first_error.is_none() {
2698                        first_error = Some(err);
2699                    }
2700                }
2701                Err(_canceled) => {
2702                    if first_error.is_none() {
2703                        first_error = Some(anyhow!("Worktree creation was canceled"));
2704                    }
2705                }
2706            }
2707        }
2708
2709        let Some(err) = first_error else {
2710            return Ok(created_paths);
2711        };
2712
2713        // Rollback all attempted worktrees (both successful and failed)
2714        let mut rollback_futures = Vec::new();
2715        for (rollback_repo, rollback_path) in &repos_and_paths {
2716            let receiver = cx
2717                .update(|_, cx| {
2718                    rollback_repo.update(cx, |repo, _cx| {
2719                        repo.remove_worktree(rollback_path.clone(), true)
2720                    })
2721                })
2722                .ok();
2723
2724            rollback_futures.push((rollback_path.clone(), receiver));
2725        }
2726
2727        let mut rollback_failures: Vec<String> = Vec::new();
2728        for (path, receiver_opt) in rollback_futures {
2729            let mut git_remove_failed = false;
2730
2731            if let Some(receiver) = receiver_opt {
2732                match receiver.await {
2733                    Ok(Ok(())) => {}
2734                    Ok(Err(rollback_err)) => {
2735                        log::error!(
2736                            "git worktree remove failed for {}: {rollback_err}",
2737                            path.display()
2738                        );
2739                        git_remove_failed = true;
2740                    }
2741                    Err(canceled) => {
2742                        log::error!(
2743                            "git worktree remove failed for {}: {canceled}",
2744                            path.display()
2745                        );
2746                        git_remove_failed = true;
2747                    }
2748                }
2749            } else {
2750                log::error!(
2751                    "failed to dispatch git worktree remove for {}",
2752                    path.display()
2753                );
2754                git_remove_failed = true;
2755            }
2756
2757            // `git worktree remove` normally removes this directory, but since
2758            // `git worktree remove` failed (or wasn't dispatched), manually rm the directory.
2759            if git_remove_failed {
2760                if let Err(fs_err) = fs
2761                    .remove_dir(
2762                        &path,
2763                        fs::RemoveOptions {
2764                            recursive: true,
2765                            ignore_if_not_exists: true,
2766                        },
2767                    )
2768                    .await
2769                {
2770                    let msg = format!("{}: failed to remove directory: {fs_err}", path.display());
2771                    log::error!("{}", msg);
2772                    rollback_failures.push(msg);
2773                }
2774            }
2775        }
2776        let mut error_message = format!("Failed to create worktree: {err}");
2777        if !rollback_failures.is_empty() {
2778            error_message.push_str("\n\nFailed to clean up: ");
2779            error_message.push_str(&rollback_failures.join(", "));
2780        }
2781        Err(anyhow!(error_message))
2782    }
2783
2784    fn set_worktree_creation_error(
2785        &mut self,
2786        message: SharedString,
2787        window: &mut Window,
2788        cx: &mut Context<Self>,
2789    ) {
2790        if let Some((_, status)) = &mut self.worktree_creation_status {
2791            *status = WorktreeCreationStatus::Error(message);
2792        }
2793        if matches!(self.active_view, ActiveView::Uninitialized) {
2794            let selected_agent = self.selected_agent.clone();
2795            self.new_agent_thread(selected_agent, window, cx);
2796        }
2797        cx.notify();
2798    }
2799
2800    fn handle_worktree_requested(
2801        &mut self,
2802        content: Vec<acp::ContentBlock>,
2803        args: WorktreeCreationArgs,
2804        window: &mut Window,
2805        cx: &mut Context<Self>,
2806    ) {
2807        if matches!(
2808            self.worktree_creation_status,
2809            Some((_, WorktreeCreationStatus::Creating))
2810        ) {
2811            return;
2812        }
2813
2814        let conversation_view_id = self
2815            .active_conversation_view()
2816            .map(|v| v.entity_id())
2817            .unwrap_or_else(|| EntityId::from(0u64));
2818        self.worktree_creation_status =
2819            Some((conversation_view_id, WorktreeCreationStatus::Creating));
2820        cx.notify();
2821
2822        let (git_repos, non_git_paths) = self.classify_worktrees(cx);
2823
2824        if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
2825            self.set_worktree_creation_error(
2826                "No git repositories found in the project".into(),
2827                window,
2828                cx,
2829            );
2830            return;
2831        }
2832
2833        let (branch_receivers, worktree_receivers, worktree_directory_setting) =
2834            if matches!(args, WorktreeCreationArgs::New { .. }) {
2835                (
2836                    Some(
2837                        git_repos
2838                            .iter()
2839                            .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
2840                            .collect::<Vec<_>>(),
2841                    ),
2842                    Some(
2843                        git_repos
2844                            .iter()
2845                            .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
2846                            .collect::<Vec<_>>(),
2847                    ),
2848                    Some(
2849                        ProjectSettings::get_global(cx)
2850                            .git
2851                            .worktree_directory
2852                            .clone(),
2853                    ),
2854                )
2855            } else {
2856                (None, None, None)
2857            };
2858
2859        let active_file_path = self.workspace.upgrade().and_then(|workspace| {
2860            let workspace = workspace.read(cx);
2861            let active_item = workspace.active_item(cx)?;
2862            let project_path = active_item.project_path(cx)?;
2863            workspace
2864                .project()
2865                .read(cx)
2866                .absolute_path(&project_path, cx)
2867        });
2868
2869        let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
2870
2871        if remote_connection_options.is_some() {
2872            let is_disconnected = self
2873                .project
2874                .read(cx)
2875                .remote_client()
2876                .is_some_and(|client| client.read(cx).is_disconnected());
2877            if is_disconnected {
2878                self.set_worktree_creation_error(
2879                    "Cannot create worktree: remote connection is not active".into(),
2880                    window,
2881                    cx,
2882                );
2883                return;
2884            }
2885        }
2886
2887        let workspace = self.workspace.clone();
2888        let window_handle = window
2889            .window_handle()
2890            .downcast::<workspace::MultiWorkspace>();
2891
2892        let selected_agent = self.selected_agent();
2893
2894        let task = cx.spawn_in(window, async move |this, cx| {
2895            let (all_paths, path_remapping, has_non_git) = match args {
2896                WorktreeCreationArgs::New {
2897                    worktree_name,
2898                    branch_target,
2899                } => {
2900                    let branch_receivers = branch_receivers
2901                        .expect("branch receivers must be prepared for new worktree creation");
2902                    let worktree_receivers = worktree_receivers
2903                        .expect("worktree receivers must be prepared for new worktree creation");
2904                    let worktree_directory_setting = worktree_directory_setting
2905                        .expect("worktree directory must be prepared for new worktree creation");
2906
2907                    let mut existing_branches = HashSet::default();
2908                    for result in futures::future::join_all(branch_receivers).await {
2909                        match result {
2910                            Ok(Ok(branches)) => {
2911                                for branch in branches {
2912                                    existing_branches.insert(branch.name().to_string());
2913                                }
2914                            }
2915                            Ok(Err(err)) => {
2916                                Err::<(), _>(err).log_err();
2917                            }
2918                            Err(_) => {}
2919                        }
2920                    }
2921
2922                    let mut occupied_branches = HashSet::default();
2923                    for result in futures::future::join_all(worktree_receivers).await {
2924                        match result {
2925                            Ok(Ok(worktrees)) => {
2926                                for worktree in worktrees {
2927                                    if let Some(branch_name) = worktree.branch_name() {
2928                                        occupied_branches.insert(branch_name.to_string());
2929                                    }
2930                                }
2931                            }
2932                            Ok(Err(err)) => {
2933                                Err::<(), _>(err).log_err();
2934                            }
2935                            Err(_) => {}
2936                        }
2937                    }
2938
2939                    let (branch_name, use_existing_branch, start_point) =
2940                        match Self::resolve_worktree_branch_target(
2941                            &branch_target,
2942                            &existing_branches,
2943                            &occupied_branches,
2944                        ) {
2945                            Ok(target) => target,
2946                            Err(err) => {
2947                                this.update_in(cx, |this, window, cx| {
2948                                    this.set_worktree_creation_error(
2949                                        err.to_string().into(),
2950                                        window,
2951                                        cx,
2952                                    );
2953                                })?;
2954                                return anyhow::Ok(());
2955                            }
2956                        };
2957
2958                    let (creation_infos, path_remapping) =
2959                        match this.update_in(cx, |_this, _window, cx| {
2960                            Self::start_worktree_creations(
2961                                &git_repos,
2962                                worktree_name,
2963                                &branch_name,
2964                                use_existing_branch,
2965                                start_point,
2966                                &worktree_directory_setting,
2967                                cx,
2968                            )
2969                        }) {
2970                            Ok(Ok(result)) => result,
2971                            Ok(Err(err)) | Err(err) => {
2972                                this.update_in(cx, |this, window, cx| {
2973                                    this.set_worktree_creation_error(
2974                                        format!("Failed to validate worktree directory: {err}")
2975                                            .into(),
2976                                        window,
2977                                        cx,
2978                                    );
2979                                })
2980                                .log_err();
2981                                return anyhow::Ok(());
2982                            }
2983                        };
2984
2985                    let fs = cx.update(|_, cx| <dyn Fs>::global(cx))?;
2986
2987                    let created_paths =
2988                        match Self::await_and_rollback_on_failure(creation_infos, fs, cx).await {
2989                            Ok(paths) => paths,
2990                            Err(err) => {
2991                                this.update_in(cx, |this, window, cx| {
2992                                    this.set_worktree_creation_error(
2993                                        format!("{err}").into(),
2994                                        window,
2995                                        cx,
2996                                    );
2997                                })?;
2998                                return anyhow::Ok(());
2999                            }
3000                        };
3001
3002                    let mut all_paths = created_paths;
3003                    let has_non_git = !non_git_paths.is_empty();
3004                    all_paths.extend(non_git_paths.iter().cloned());
3005                    (all_paths, path_remapping, has_non_git)
3006                }
3007                WorktreeCreationArgs::Linked { worktree_path } => {
3008                    let mut all_paths = vec![worktree_path];
3009                    let has_non_git = !non_git_paths.is_empty();
3010                    all_paths.extend(non_git_paths.iter().cloned());
3011                    (all_paths, Vec::new(), has_non_git)
3012                }
3013            };
3014
3015            if workspace.upgrade().is_none() {
3016                this.update_in(cx, |this, window, cx| {
3017                    this.set_worktree_creation_error(
3018                        "Workspace no longer available".into(),
3019                        window,
3020                        cx,
3021                    );
3022                })?;
3023                return anyhow::Ok(());
3024            }
3025
3026            let this_for_error = this.clone();
3027            if let Err(err) = Self::open_worktree_workspace_and_start_thread(
3028                this,
3029                all_paths,
3030                window_handle,
3031                active_file_path,
3032                path_remapping,
3033                non_git_paths,
3034                has_non_git,
3035                content,
3036                selected_agent,
3037                remote_connection_options,
3038                cx,
3039            )
3040            .await
3041            {
3042                this_for_error
3043                    .update_in(cx, |this, window, cx| {
3044                        this.set_worktree_creation_error(
3045                            format!("Failed to set up workspace: {err}").into(),
3046                            window,
3047                            cx,
3048                        );
3049                    })
3050                    .log_err();
3051            }
3052            anyhow::Ok(())
3053        });
3054
3055        self._worktree_creation_task = Some(cx.background_spawn(async move {
3056            task.await.log_err();
3057        }));
3058    }
3059
3060    async fn open_worktree_workspace_and_start_thread(
3061        this: WeakEntity<Self>,
3062        all_paths: Vec<PathBuf>,
3063        window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
3064        active_file_path: Option<PathBuf>,
3065        path_remapping: Vec<(PathBuf, PathBuf)>,
3066        non_git_paths: Vec<PathBuf>,
3067        has_non_git: bool,
3068        content: Vec<acp::ContentBlock>,
3069        selected_agent: Option<Agent>,
3070        remote_connection_options: Option<RemoteConnectionOptions>,
3071        cx: &mut AsyncWindowContext,
3072    ) -> Result<()> {
3073        let window_handle = window_handle
3074            .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?;
3075
3076        let (workspace_task, modal_workspace) =
3077            window_handle.update(cx, |multi_workspace, window, cx| {
3078                let path_list = PathList::new(&all_paths);
3079                let active_workspace = multi_workspace.workspace().clone();
3080                let modal_workspace = active_workspace.clone();
3081
3082                let task = multi_workspace.find_or_create_workspace(
3083                    path_list,
3084                    remote_connection_options,
3085                    None,
3086                    move |connection_options, window, cx| {
3087                        remote_connection::connect_with_modal(
3088                            &active_workspace,
3089                            connection_options,
3090                            window,
3091                            cx,
3092                        )
3093                    },
3094                    window,
3095                    cx,
3096                );
3097                (task, modal_workspace)
3098            })?;
3099
3100        let result = workspace_task.await;
3101        remote_connection::dismiss_connection_modal(&modal_workspace, cx);
3102        let new_workspace = result?;
3103
3104        let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
3105
3106        if let Some(task) = panels_task {
3107            task.await.log_err();
3108        }
3109
3110        new_workspace
3111            .update(cx, |workspace, cx| {
3112                workspace.project().read(cx).wait_for_initial_scan(cx)
3113            })
3114            .await;
3115
3116        new_workspace
3117            .update(cx, |workspace, cx| {
3118                let repos = workspace
3119                    .project()
3120                    .read(cx)
3121                    .repositories(cx)
3122                    .values()
3123                    .cloned()
3124                    .collect::<Vec<_>>();
3125
3126                let tasks = repos
3127                    .into_iter()
3128                    .map(|repo| repo.update(cx, |repo, _| repo.barrier()));
3129                futures::future::join_all(tasks)
3130            })
3131            .await;
3132
3133        let initial_content = AgentInitialContent::ContentBlock {
3134            blocks: content,
3135            auto_submit: true,
3136        };
3137
3138        window_handle.update(cx, |_multi_workspace, window, cx| {
3139            new_workspace.update(cx, |workspace, cx| {
3140                if has_non_git {
3141                    let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
3142                    workspace.show_toast(
3143                        workspace::Toast::new(
3144                            toast_id,
3145                            "Some project folders are not git repositories. \
3146                             They were included as-is without creating a worktree.",
3147                        ),
3148                        cx,
3149                    );
3150                }
3151
3152                // If we had an active buffer, remap its path and reopen it.
3153                let had_active_file = active_file_path.is_some();
3154                let remapped_active_path = active_file_path.and_then(|original_path| {
3155                    let best_match = path_remapping
3156                        .iter()
3157                        .filter_map(|(old_root, new_root)| {
3158                            original_path.strip_prefix(old_root).ok().map(|relative| {
3159                                (old_root.components().count(), new_root.join(relative))
3160                            })
3161                        })
3162                        .max_by_key(|(depth, _)| *depth);
3163
3164                    if let Some((_, remapped_path)) = best_match {
3165                        return Some(remapped_path);
3166                    }
3167
3168                    for non_git in &non_git_paths {
3169                        if original_path.starts_with(non_git) {
3170                            return Some(original_path);
3171                        }
3172                    }
3173                    None
3174                });
3175
3176                if had_active_file && remapped_active_path.is_none() {
3177                    log::warn!(
3178                        "Active file could not be remapped to the new worktree; it will not be reopened"
3179                    );
3180                }
3181
3182                if let Some(path) = remapped_active_path {
3183                    let open_task = workspace.open_paths(
3184                        vec![path],
3185                        workspace::OpenOptions::default(),
3186                        None,
3187                        window,
3188                        cx,
3189                    );
3190                    cx.spawn(async move |_, _| -> anyhow::Result<()> {
3191                        for item in open_task.await.into_iter().flatten() {
3192                            item?;
3193                        }
3194                        Ok(())
3195                    })
3196                    .detach_and_log_err(cx);
3197                }
3198
3199                workspace.focus_panel::<AgentPanel>(window, cx);
3200
3201                // If no active buffer was open, zoom the agent panel
3202                // (equivalent to cmd-esc fullscreen behavior).
3203                // This must happen after focus_panel, which activates
3204                // and opens the panel in the dock.
3205
3206                if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
3207                    panel.update(cx, |panel, cx| {
3208                        panel.external_thread(
3209                            selected_agent,
3210                            None,
3211                            None,
3212                            None,
3213                            Some(initial_content),
3214                            true,
3215                            window,
3216                            cx,
3217                        );
3218                    });
3219                }
3220            });
3221        })?;
3222
3223        window_handle.update(cx, |multi_workspace, window, cx| {
3224            multi_workspace.activate(new_workspace.clone(), window, cx);
3225
3226            new_workspace.update(cx, |workspace, cx| {
3227                workspace.run_create_worktree_tasks(window, cx);
3228            })
3229        })?;
3230
3231        this.update_in(cx, |this, window, cx| {
3232            this.worktree_creation_status = None;
3233
3234            if let Some(thread_view) = this.active_thread_view(cx) {
3235                thread_view.update(cx, |thread_view, cx| {
3236                    thread_view
3237                        .message_editor
3238                        .update(cx, |editor, cx| editor.clear(window, cx));
3239                });
3240            }
3241
3242            this.start_thread_in = StartThreadIn::LocalProject;
3243            this.serialize(cx);
3244            cx.notify();
3245        })?;
3246
3247        anyhow::Ok(())
3248    }
3249}
3250
3251impl Focusable for AgentPanel {
3252    fn focus_handle(&self, cx: &App) -> FocusHandle {
3253        match &self.active_view {
3254            ActiveView::Uninitialized => self.focus_handle.clone(),
3255            ActiveView::AgentThread {
3256                conversation_view, ..
3257            } => conversation_view.focus_handle(cx),
3258            ActiveView::Configuration => {
3259                if let Some(configuration) = self.configuration.as_ref() {
3260                    configuration.focus_handle(cx)
3261                } else {
3262                    self.focus_handle.clone()
3263                }
3264            }
3265        }
3266    }
3267}
3268
3269fn agent_panel_dock_position(cx: &App) -> DockPosition {
3270    AgentSettings::get_global(cx).dock.into()
3271}
3272
3273pub enum AgentPanelEvent {
3274    ActiveViewChanged,
3275    ThreadFocused,
3276    BackgroundThreadChanged,
3277    MessageSentOrQueued { session_id: acp::SessionId },
3278}
3279
3280impl EventEmitter<PanelEvent> for AgentPanel {}
3281impl EventEmitter<AgentPanelEvent> for AgentPanel {}
3282
3283impl Panel for AgentPanel {
3284    fn persistent_name() -> &'static str {
3285        "AgentPanel"
3286    }
3287
3288    fn panel_key() -> &'static str {
3289        AGENT_PANEL_KEY
3290    }
3291
3292    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
3293        agent_panel_dock_position(cx)
3294    }
3295
3296    fn position_is_valid(&self, position: DockPosition) -> bool {
3297        position != DockPosition::Bottom
3298    }
3299
3300    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
3301        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3302            settings
3303                .agent
3304                .get_or_insert_default()
3305                .set_dock(position.into());
3306        });
3307    }
3308
3309    fn default_size(&self, window: &Window, cx: &App) -> Pixels {
3310        let settings = AgentSettings::get_global(cx);
3311        match self.position(window, cx) {
3312            DockPosition::Left | DockPosition::Right => settings.default_width,
3313            DockPosition::Bottom => settings.default_height,
3314        }
3315    }
3316
3317    fn min_size(&self, window: &Window, cx: &App) -> Option<Pixels> {
3318        match self.position(window, cx) {
3319            DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH),
3320            DockPosition::Bottom => None,
3321        }
3322    }
3323
3324    fn supports_flexible_size(&self) -> bool {
3325        true
3326    }
3327
3328    fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
3329        AgentSettings::get_global(cx).flexible
3330    }
3331
3332    fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
3333        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
3334            settings
3335                .agent
3336                .get_or_insert_default()
3337                .set_flexible_size(flexible);
3338        });
3339    }
3340
3341    fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
3342        if active
3343            && matches!(self.active_view, ActiveView::Uninitialized)
3344            && !matches!(
3345                self.worktree_creation_status,
3346                Some((_, WorktreeCreationStatus::Creating))
3347            )
3348        {
3349            let id = self.create_draft(window, cx);
3350            self.activate_draft(id, false, window, cx);
3351        }
3352    }
3353
3354    fn remote_id() -> Option<proto::PanelId> {
3355        Some(proto::PanelId::AssistantPanel)
3356    }
3357
3358    fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
3359        (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
3360    }
3361
3362    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
3363        Some("Agent Panel")
3364    }
3365
3366    fn toggle_action(&self) -> Box<dyn Action> {
3367        Box::new(ToggleFocus)
3368    }
3369
3370    fn activation_priority(&self) -> u32 {
3371        0
3372    }
3373
3374    fn enabled(&self, cx: &App) -> bool {
3375        AgentSettings::get_global(cx).enabled(cx)
3376    }
3377
3378    fn is_agent_panel(&self) -> bool {
3379        true
3380    }
3381
3382    fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
3383        self.zoomed
3384    }
3385
3386    fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
3387        self.zoomed = zoomed;
3388        cx.notify();
3389    }
3390}
3391
3392impl AgentPanel {
3393    fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
3394        let content = match &self.active_view {
3395            ActiveView::AgentThread { conversation_view } => {
3396                let server_view_ref = conversation_view.read(cx);
3397                let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
3398                    && server_view_ref.root_thread(cx).map_or(false, |tv| {
3399                        tv.read(cx).thread.read(cx).has_provisional_title()
3400                    });
3401
3402                if let Some(title_editor) = server_view_ref
3403                    .root_thread(cx)
3404                    .map(|r| r.read(cx).title_editor.clone())
3405                {
3406                    if is_generating_title {
3407                        Label::new(DEFAULT_THREAD_TITLE)
3408                            .color(Color::Muted)
3409                            .truncate()
3410                            .with_animation(
3411                                "generating_title",
3412                                Animation::new(Duration::from_secs(2))
3413                                    .repeat()
3414                                    .with_easing(pulsating_between(0.4, 0.8)),
3415                                |label, delta| label.alpha(delta),
3416                            )
3417                            .into_any_element()
3418                    } else {
3419                        div()
3420                            .w_full()
3421                            .on_action({
3422                                let conversation_view = conversation_view.downgrade();
3423                                move |_: &menu::Confirm, window, cx| {
3424                                    if let Some(conversation_view) = conversation_view.upgrade() {
3425                                        conversation_view.focus_handle(cx).focus(window, cx);
3426                                    }
3427                                }
3428                            })
3429                            .on_action({
3430                                let conversation_view = conversation_view.downgrade();
3431                                move |_: &editor::actions::Cancel, window, cx| {
3432                                    if let Some(conversation_view) = conversation_view.upgrade() {
3433                                        conversation_view.focus_handle(cx).focus(window, cx);
3434                                    }
3435                                }
3436                            })
3437                            .child(title_editor)
3438                            .into_any_element()
3439                    }
3440                } else {
3441                    Label::new(conversation_view.read(cx).title(cx))
3442                        .color(Color::Muted)
3443                        .truncate()
3444                        .into_any_element()
3445                }
3446            }
3447            ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
3448            ActiveView::Uninitialized => Label::new("Agent").truncate().into_any_element(),
3449        };
3450
3451        h_flex()
3452            .key_context("TitleEditor")
3453            .id("TitleEditor")
3454            .flex_grow()
3455            .w_full()
3456            .max_w_full()
3457            .overflow_x_scroll()
3458            .child(content)
3459            .into_any()
3460    }
3461
3462    fn handle_regenerate_thread_title(conversation_view: Entity<ConversationView>, cx: &mut App) {
3463        conversation_view.update(cx, |conversation_view, cx| {
3464            if let Some(thread) = conversation_view.as_native_thread(cx) {
3465                thread.update(cx, |thread, cx| {
3466                    thread.generate_title(cx);
3467                });
3468            }
3469        });
3470    }
3471
3472    fn render_panel_options_menu(
3473        &self,
3474        _window: &mut Window,
3475        cx: &mut Context<Self>,
3476    ) -> impl IntoElement {
3477        let focus_handle = self.focus_handle(cx);
3478
3479        let conversation_view = match &self.active_view {
3480            ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
3481            _ => None,
3482        };
3483        let thread_with_messages = match &self.active_view {
3484            ActiveView::AgentThread { conversation_view } => {
3485                conversation_view.read(cx).has_user_submitted_prompt(cx)
3486            }
3487            _ => false,
3488        };
3489        let has_auth_methods = match &self.active_view {
3490            ActiveView::AgentThread { conversation_view } => {
3491                conversation_view.read(cx).has_auth_methods()
3492            }
3493            _ => false,
3494        };
3495
3496        PopoverMenu::new("agent-options-menu")
3497            .trigger_with_tooltip(
3498                IconButton::new("agent-options-menu", IconName::Ellipsis)
3499                    .icon_size(IconSize::Small),
3500                {
3501                    let focus_handle = focus_handle.clone();
3502                    move |_window, cx| {
3503                        Tooltip::for_action_in(
3504                            "Toggle Agent Menu",
3505                            &ToggleOptionsMenu,
3506                            &focus_handle,
3507                            cx,
3508                        )
3509                    }
3510                },
3511            )
3512            .anchor(Corner::TopRight)
3513            .with_handle(self.agent_panel_menu_handle.clone())
3514            .menu({
3515                move |window, cx| {
3516                    Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
3517                        menu = menu.context(focus_handle.clone());
3518
3519                        if thread_with_messages {
3520                            menu = menu.header("Current Thread");
3521
3522                            if let Some(conversation_view) = conversation_view.as_ref() {
3523                                menu = menu
3524                                    .entry("Regenerate Thread Title", None, {
3525                                        let conversation_view = conversation_view.clone();
3526                                        move |_, cx| {
3527                                            Self::handle_regenerate_thread_title(
3528                                                conversation_view.clone(),
3529                                                cx,
3530                                            );
3531                                        }
3532                                    })
3533                                    .separator();
3534                            }
3535                        }
3536
3537                        menu = menu
3538                            .header("MCP Servers")
3539                            .action(
3540                                "View Server Extensions",
3541                                Box::new(zed_actions::Extensions {
3542                                    category_filter: Some(
3543                                        zed_actions::ExtensionCategoryFilter::ContextServers,
3544                                    ),
3545                                    id: None,
3546                                }),
3547                            )
3548                            .action("Add Custom Server…", Box::new(AddContextServer))
3549                            .separator()
3550                            .action("Rules", Box::new(OpenRulesLibrary::default()))
3551                            .action("Profiles", Box::new(ManageProfiles::default()))
3552                            .action("Settings", Box::new(OpenSettings))
3553                            .separator()
3554                            .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
3555
3556                        if has_auth_methods {
3557                            menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
3558                        }
3559
3560                        menu
3561                    }))
3562                }
3563            })
3564    }
3565
3566    fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3567        let focus_handle = self.focus_handle(cx);
3568
3569        IconButton::new("go-back", IconName::ArrowLeft)
3570            .icon_size(IconSize::Small)
3571            .on_click(cx.listener(|this, _, window, cx| {
3572                this.go_back(&workspace::GoBack, window, cx);
3573            }))
3574            .tooltip({
3575                move |_window, cx| {
3576                    Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
3577                }
3578            })
3579    }
3580
3581    fn project_has_git_repository(&self, cx: &App) -> bool {
3582        !self.project.read(cx).repositories(cx).is_empty()
3583    }
3584
3585    fn is_active_view_creating_worktree(&self, _cx: &App) -> bool {
3586        match &self.worktree_creation_status {
3587            Some((view_id, WorktreeCreationStatus::Creating)) => {
3588                self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id)
3589            }
3590            _ => false,
3591        }
3592    }
3593
3594    fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3595        let focus_handle = self.focus_handle(cx);
3596
3597        let is_creating = self.is_active_view_creating_worktree(cx);
3598
3599        let trigger_parts = self
3600            .start_thread_in
3601            .trigger_label(self.project.read(cx), cx);
3602
3603        let icon = if self.start_thread_in_menu_handle.is_deployed() {
3604            IconName::ChevronUp
3605        } else {
3606            IconName::ChevronDown
3607        };
3608
3609        let trigger_button = ButtonLike::new("thread-target-trigger")
3610            .disabled(is_creating)
3611            .when_some(trigger_parts.prefix, |this, prefix| {
3612                this.child(Label::new(prefix).color(Color::Muted))
3613            })
3614            .child(Label::new(trigger_parts.label))
3615            .when_some(trigger_parts.suffix, |this, suffix| {
3616                this.child(Label::new(suffix).color(Color::Muted))
3617            })
3618            .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3619
3620        let project = self.project.clone();
3621        let current_target = self.start_thread_in.clone();
3622        let fs = self.fs.clone();
3623
3624        PopoverMenu::new("thread-target-selector")
3625            .trigger_with_tooltip(trigger_button, {
3626                move |_window, cx| {
3627                    Tooltip::for_action_in(
3628                        "Start Thread In…",
3629                        &CycleStartThreadIn,
3630                        &focus_handle,
3631                        cx,
3632                    )
3633                }
3634            })
3635            .menu(move |window, cx| {
3636                let fs = fs.clone();
3637                Some(cx.new(|cx| {
3638                    ThreadWorktreePicker::new(project.clone(), &current_target, fs, window, cx)
3639                }))
3640            })
3641            .with_handle(self.start_thread_in_menu_handle.clone())
3642            .anchor(Corner::TopLeft)
3643            .offset(gpui::Point {
3644                x: px(1.0),
3645                y: px(1.0),
3646            })
3647    }
3648
3649    fn render_new_worktree_branch_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
3650        let is_creating = self.is_active_view_creating_worktree(cx);
3651
3652        let project_ref = self.project.read(cx);
3653        let trigger_parts = self
3654            .start_thread_in
3655            .branch_trigger_label(project_ref, cx)
3656            .unwrap_or_else(|| StartThreadInLabel {
3657                prefix: Some("From:".into()),
3658                label: "HEAD".into(),
3659                suffix: None,
3660            });
3661
3662        let icon = if self.thread_branch_menu_handle.is_deployed() {
3663            IconName::ChevronUp
3664        } else {
3665            IconName::ChevronDown
3666        };
3667
3668        let trigger_button = ButtonLike::new("thread-branch-trigger")
3669            .disabled(is_creating)
3670            .when_some(trigger_parts.prefix, |this, prefix| {
3671                this.child(Label::new(prefix).color(Color::Muted))
3672            })
3673            .child(Label::new(trigger_parts.label))
3674            .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
3675
3676        let project = self.project.clone();
3677        let current_target = self.start_thread_in.clone();
3678
3679        PopoverMenu::new("thread-branch-selector")
3680            .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
3681            .menu(move |window, cx| {
3682                Some(cx.new(|cx| {
3683                    ThreadBranchPicker::new(project.clone(), &current_target, window, cx)
3684                }))
3685            })
3686            .with_handle(self.thread_branch_menu_handle.clone())
3687            .anchor(Corner::TopLeft)
3688            .offset(gpui::Point {
3689                x: px(1.0),
3690                y: px(1.0),
3691            })
3692    }
3693
3694    fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3695        let agent_server_store = self.project.read(cx).agent_server_store().clone();
3696        let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some();
3697        let focus_handle = self.focus_handle(cx);
3698
3699        let (selected_agent_custom_icon, selected_agent_label) =
3700            if let Agent::Custom { id, .. } = &self.selected_agent {
3701                let store = agent_server_store.read(cx);
3702                let icon = store.agent_icon(&id);
3703
3704                let label = store
3705                    .agent_display_name(&id)
3706                    .unwrap_or_else(|| self.selected_agent.label());
3707                (icon, label)
3708            } else {
3709                (None, self.selected_agent.label())
3710            };
3711
3712        let active_thread = match &self.active_view {
3713            ActiveView::AgentThread { conversation_view } => {
3714                conversation_view.read(cx).as_native_thread(cx)
3715            }
3716            ActiveView::Uninitialized | ActiveView::Configuration => None,
3717        };
3718
3719        let new_thread_menu_builder: Rc<
3720            dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
3721        > = {
3722            let selected_agent = self.selected_agent.clone();
3723            let is_agent_selected = move |agent: Agent| selected_agent == agent;
3724
3725            let workspace = self.workspace.clone();
3726            let is_via_collab = workspace
3727                .update(cx, |workspace, cx| {
3728                    workspace.project().read(cx).is_via_collab()
3729                })
3730                .unwrap_or_default();
3731
3732            let focus_handle = focus_handle.clone();
3733            let agent_server_store = agent_server_store;
3734
3735            Rc::new(move |window, cx| {
3736                telemetry::event!("New Thread Clicked");
3737
3738                let active_thread = active_thread.clone();
3739                Some(ContextMenu::build(window, cx, |menu, _window, cx| {
3740                    menu.context(focus_handle.clone())
3741                        .when_some(active_thread, |this, active_thread| {
3742                            let thread = active_thread.read(cx);
3743
3744                            if !thread.is_empty() {
3745                                let session_id = thread.id().clone();
3746                                this.item(
3747                                    ContextMenuEntry::new("New From Summary")
3748                                        .icon(IconName::ThreadFromSummary)
3749                                        .icon_color(Color::Muted)
3750                                        .handler(move |window, cx| {
3751                                            window.dispatch_action(
3752                                                Box::new(NewNativeAgentThreadFromSummary {
3753                                                    from_session_id: session_id.clone(),
3754                                                }),
3755                                                cx,
3756                                            );
3757                                        }),
3758                                )
3759                            } else {
3760                                this
3761                            }
3762                        })
3763                        .item(
3764                            ContextMenuEntry::new("Zed Agent")
3765                                .when(is_agent_selected(Agent::NativeAgent), |this| {
3766                                    this.action(Box::new(NewExternalAgentThread { agent: None }))
3767                                })
3768                                .icon(IconName::ZedAgent)
3769                                .icon_color(Color::Muted)
3770                                .handler({
3771                                    let workspace = workspace.clone();
3772                                    move |window, cx| {
3773                                        if let Some(workspace) = workspace.upgrade() {
3774                                            workspace.update(cx, |workspace, cx| {
3775                                                if let Some(panel) =
3776                                                    workspace.panel::<AgentPanel>(cx)
3777                                                {
3778                                                    panel.update(cx, |panel, cx| {
3779                                                        panel.selected_agent = Agent::NativeAgent;
3780                                                        panel.reset_start_thread_in_to_default(cx);
3781                                                        let id = panel.create_draft(window, cx);
3782                                                        panel.activate_draft(id, true, window, cx);
3783                                                    });
3784                                                }
3785                                            });
3786                                        }
3787                                    }
3788                                }),
3789                        )
3790                        .map(|mut menu| {
3791                            let agent_server_store = agent_server_store.read(cx);
3792                            let registry_store = project::AgentRegistryStore::try_global(cx);
3793                            let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
3794
3795                            struct AgentMenuItem {
3796                                id: AgentId,
3797                                display_name: SharedString,
3798                            }
3799
3800                            let agent_items = agent_server_store
3801                                .external_agents()
3802                                .map(|agent_id| {
3803                                    let display_name = agent_server_store
3804                                        .agent_display_name(agent_id)
3805                                        .or_else(|| {
3806                                            registry_store_ref
3807                                                .as_ref()
3808                                                .and_then(|store| store.agent(agent_id))
3809                                                .map(|a| a.name().clone())
3810                                        })
3811                                        .unwrap_or_else(|| agent_id.0.clone());
3812                                    AgentMenuItem {
3813                                        id: agent_id.clone(),
3814                                        display_name,
3815                                    }
3816                                })
3817                                .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
3818                                .collect::<Vec<_>>();
3819
3820                            if !agent_items.is_empty() {
3821                                menu = menu.separator().header("External Agents");
3822                            }
3823                            for item in &agent_items {
3824                                let mut entry = ContextMenuEntry::new(item.display_name.clone());
3825
3826                                let icon_path =
3827                                    agent_server_store.agent_icon(&item.id).or_else(|| {
3828                                        registry_store_ref
3829                                            .as_ref()
3830                                            .and_then(|store| store.agent(&item.id))
3831                                            .and_then(|a| a.icon_path().cloned())
3832                                    });
3833
3834                                if let Some(icon_path) = icon_path {
3835                                    entry = entry.custom_icon_svg(icon_path);
3836                                } else {
3837                                    entry = entry.icon(IconName::Sparkle);
3838                                }
3839
3840                                entry = entry
3841                                    .when(
3842                                        is_agent_selected(Agent::Custom {
3843                                            id: item.id.clone(),
3844                                        }),
3845                                        |this| {
3846                                            this.action(Box::new(NewExternalAgentThread {
3847                                                agent: None,
3848                                            }))
3849                                        },
3850                                    )
3851                                    .icon_color(Color::Muted)
3852                                    .disabled(is_via_collab)
3853                                    .handler({
3854                                        let workspace = workspace.clone();
3855                                        let agent_id = item.id.clone();
3856                                        move |window, cx| {
3857                                            if let Some(workspace) = workspace.upgrade() {
3858                                                workspace.update(cx, |workspace, cx| {
3859                                                    if let Some(panel) =
3860                                                        workspace.panel::<AgentPanel>(cx)
3861                                                    {
3862                                                        panel.update(cx, |panel, cx| {
3863                                                            panel.selected_agent = Agent::Custom {
3864                                                                id: agent_id.clone(),
3865                                                            };
3866                                                            panel.reset_start_thread_in_to_default(
3867                                                                cx,
3868                                                            );
3869                                                            let id = panel.create_draft(window, cx);
3870                                                            panel.activate_draft(
3871                                                                id, true, window, cx,
3872                                                            );
3873                                                        });
3874                                                    }
3875                                                });
3876                                            }
3877                                        }
3878                                    });
3879
3880                                menu = menu.item(entry);
3881                            }
3882
3883                            menu
3884                        })
3885                        .separator()
3886                        .item(
3887                            ContextMenuEntry::new("Add More Agents")
3888                                .icon(IconName::Plus)
3889                                .icon_color(Color::Muted)
3890                                .handler({
3891                                    move |window, cx| {
3892                                        window
3893                                            .dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
3894                                    }
3895                                }),
3896                        )
3897                }))
3898            })
3899        };
3900
3901        let is_thread_loading = self
3902            .active_conversation_view()
3903            .map(|thread| thread.read(cx).is_loading())
3904            .unwrap_or(false);
3905
3906        let has_custom_icon = selected_agent_custom_icon.is_some();
3907        let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
3908        let selected_agent_builtin_icon = self.selected_agent.icon();
3909        let selected_agent_label_for_tooltip = selected_agent_label.clone();
3910
3911        let selected_agent = div()
3912            .id("selected_agent_icon")
3913            .when_some(selected_agent_custom_icon, |this, icon_path| {
3914                this.px_1().child(
3915                    Icon::from_external_svg(icon_path)
3916                        .color(Color::Muted)
3917                        .size(IconSize::Small),
3918                )
3919            })
3920            .when(!has_custom_icon, |this| {
3921                this.when_some(selected_agent_builtin_icon, |this, icon| {
3922                    this.px_1().child(Icon::new(icon).color(Color::Muted))
3923                })
3924            })
3925            .tooltip(move |_, cx| {
3926                Tooltip::with_meta(
3927                    selected_agent_label_for_tooltip.clone(),
3928                    None,
3929                    "Selected Agent",
3930                    cx,
3931                )
3932            });
3933
3934        let selected_agent = if is_thread_loading {
3935            selected_agent
3936                .with_animation(
3937                    "pulsating-icon",
3938                    Animation::new(Duration::from_secs(1))
3939                        .repeat()
3940                        .with_easing(pulsating_between(0.2, 0.6)),
3941                    |icon, delta| icon.opacity(delta),
3942                )
3943                .into_any_element()
3944        } else {
3945            selected_agent.into_any_element()
3946        };
3947
3948        let is_empty_state = !self.active_thread_has_messages(cx);
3949
3950        let is_in_history_or_config = matches!(&self.active_view, ActiveView::Configuration);
3951
3952        let is_full_screen = self.is_zoomed(window, cx);
3953        let full_screen_button = if is_full_screen {
3954            IconButton::new("disable-full-screen", IconName::Minimize)
3955                .icon_size(IconSize::Small)
3956                .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
3957                .on_click(cx.listener(move |this, _, window, cx| {
3958                    this.toggle_zoom(&ToggleZoom, window, cx);
3959                }))
3960        } else {
3961            IconButton::new("enable-full-screen", IconName::Maximize)
3962                .icon_size(IconSize::Small)
3963                .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
3964                .on_click(cx.listener(move |this, _, window, cx| {
3965                    this.toggle_zoom(&ToggleZoom, window, cx);
3966                }))
3967        };
3968
3969        let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
3970
3971        let max_content_width = AgentSettings::get_global(cx).max_content_width;
3972
3973        let base_container = h_flex()
3974            .size_full()
3975            // TODO: This is only until we remove Agent settings from the panel.
3976            .when(!is_in_history_or_config, |this| {
3977                this.max_w(max_content_width).mx_auto()
3978            })
3979            .flex_none()
3980            .justify_between()
3981            .gap_2();
3982
3983        let toolbar_content = if use_v2_empty_toolbar {
3984            let (chevron_icon, icon_color, label_color) =
3985                if self.new_thread_menu_handle.is_deployed() {
3986                    (IconName::ChevronUp, Color::Accent, Color::Accent)
3987                } else {
3988                    (IconName::ChevronDown, Color::Muted, Color::Default)
3989                };
3990
3991            let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
3992                Icon::from_external_svg(icon_path)
3993                    .size(IconSize::Small)
3994                    .color(icon_color)
3995            } else {
3996                let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
3997                Icon::new(icon_name).size(IconSize::Small).color(icon_color)
3998            };
3999
4000            let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
4001                .start_icon(agent_icon)
4002                .color(label_color)
4003                .end_icon(
4004                    Icon::new(chevron_icon)
4005                        .color(icon_color)
4006                        .size(IconSize::XSmall),
4007                );
4008
4009            let agent_selector_menu = PopoverMenu::new("new_thread_menu")
4010                .trigger_with_tooltip(agent_selector_button, {
4011                    move |_window, cx| {
4012                        Tooltip::for_action_in(
4013                            "New Thread…",
4014                            &ToggleNewThreadMenu,
4015                            &focus_handle,
4016                            cx,
4017                        )
4018                    }
4019                })
4020                .menu({
4021                    let builder = new_thread_menu_builder.clone();
4022                    move |window, cx| builder(window, cx)
4023                })
4024                .with_handle(self.new_thread_menu_handle.clone())
4025                .anchor(Corner::TopLeft)
4026                .offset(gpui::Point {
4027                    x: px(1.0),
4028                    y: px(1.0),
4029                });
4030
4031            base_container
4032                .child(
4033                    h_flex()
4034                        .size_full()
4035                        .gap(DynamicSpacing::Base04.rems(cx))
4036                        .pl(DynamicSpacing::Base04.rems(cx))
4037                        .child(agent_selector_menu)
4038                        .when(
4039                            has_visible_worktrees && self.project_has_git_repository(cx),
4040                            |this| this.child(self.render_start_thread_in_selector(cx)),
4041                        )
4042                        .when(
4043                            matches!(self.start_thread_in, StartThreadIn::NewWorktree { .. }),
4044                            |this| this.child(self.render_new_worktree_branch_selector(cx)),
4045                        ),
4046                )
4047                .child(
4048                    h_flex()
4049                        .h_full()
4050                        .flex_none()
4051                        .gap_1()
4052                        .pl_1()
4053                        .pr_1()
4054                        .child(full_screen_button)
4055                        .child(self.render_panel_options_menu(window, cx)),
4056                )
4057                .into_any_element()
4058        } else {
4059            let new_thread_menu = PopoverMenu::new("new_thread_menu")
4060                .trigger_with_tooltip(
4061                    IconButton::new("new_thread_menu_btn", IconName::Plus)
4062                        .icon_size(IconSize::Small),
4063                    {
4064                        move |_window, cx| {
4065                            Tooltip::for_action_in(
4066                                "New Thread\u{2026}",
4067                                &ToggleNewThreadMenu,
4068                                &focus_handle,
4069                                cx,
4070                            )
4071                        }
4072                    },
4073                )
4074                .anchor(Corner::TopRight)
4075                .with_handle(self.new_thread_menu_handle.clone())
4076                .menu(move |window, cx| new_thread_menu_builder(window, cx));
4077
4078            base_container
4079                .child(
4080                    h_flex()
4081                        .size_full()
4082                        .gap(DynamicSpacing::Base04.rems(cx))
4083                        .pl(DynamicSpacing::Base04.rems(cx))
4084                        .child(match &self.active_view {
4085                            ActiveView::Configuration => {
4086                                self.render_toolbar_back_button(cx).into_any_element()
4087                            }
4088                            _ => selected_agent.into_any_element(),
4089                        })
4090                        .child(self.render_title_view(window, cx)),
4091                )
4092                .child(
4093                    h_flex()
4094                        .h_full()
4095                        .flex_none()
4096                        .gap_1()
4097                        .pl_1()
4098                        .pr_1()
4099                        .child(new_thread_menu)
4100                        .child(full_screen_button)
4101                        .child(self.render_panel_options_menu(window, cx)),
4102                )
4103                .into_any_element()
4104        };
4105
4106        h_flex()
4107            .id("agent-panel-toolbar")
4108            .h(Tab::container_height(cx))
4109            .flex_shrink_0()
4110            .max_w_full()
4111            .bg(cx.theme().colors().tab_bar_background)
4112            .border_b_1()
4113            .border_color(cx.theme().colors().border)
4114            .child(toolbar_content)
4115    }
4116
4117    fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4118        let (view_id, status) = self.worktree_creation_status.as_ref()?;
4119        let active_view_id = self.active_conversation_view().map(|v| v.entity_id());
4120        if active_view_id != Some(*view_id) {
4121            return None;
4122        }
4123        match status {
4124            WorktreeCreationStatus::Creating => Some(
4125                h_flex()
4126                    .absolute()
4127                    .bottom_12()
4128                    .w_full()
4129                    .p_2()
4130                    .gap_1()
4131                    .justify_center()
4132                    .bg(cx.theme().colors().editor_background)
4133                    .child(
4134                        Icon::new(IconName::LoadCircle)
4135                            .size(IconSize::Small)
4136                            .color(Color::Muted)
4137                            .with_rotate_animation(3),
4138                    )
4139                    .child(
4140                        Label::new("Creating Worktree…")
4141                            .color(Color::Muted)
4142                            .size(LabelSize::Small),
4143                    )
4144                    .into_any_element(),
4145            ),
4146            WorktreeCreationStatus::Error(message) => Some(
4147                Callout::new()
4148                    .icon(IconName::XCircleFilled)
4149                    .severity(Severity::Error)
4150                    .title("Worktree Creation Error")
4151                    .description(message.clone())
4152                    .border_position(ui::BorderPosition::Bottom)
4153                    .dismiss_action(
4154                        IconButton::new("dismiss-worktree-error", IconName::Close)
4155                            .icon_size(IconSize::Small)
4156                            .tooltip(Tooltip::text("Dismiss"))
4157                            .on_click(cx.listener(|this, _, _, cx| {
4158                                this.worktree_creation_status = None;
4159                                cx.notify();
4160                            })),
4161                    )
4162                    .into_any_element(),
4163            ),
4164        }
4165    }
4166
4167    fn should_render_trial_end_upsell(&self, cx: &mut Context<Self>) -> bool {
4168        if TrialEndUpsell::dismissed(cx) {
4169            return false;
4170        }
4171
4172        match &self.active_view {
4173            ActiveView::AgentThread { .. } => {
4174                if LanguageModelRegistry::global(cx)
4175                    .read(cx)
4176                    .default_model()
4177                    .is_some_and(|model| {
4178                        model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4179                    })
4180                {
4181                    return false;
4182                }
4183            }
4184            ActiveView::Uninitialized | ActiveView::Configuration => {
4185                return false;
4186            }
4187        }
4188
4189        let plan = self.user_store.read(cx).plan();
4190        let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
4191
4192        plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
4193    }
4194
4195    fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
4196        // We only want to show this for existing users: those who
4197        // have used the agent panel before the sidebar was introduced.
4198        // We can infer that state by users having seen the onboarding
4199        // at one point, but not the agent layout onboarding.
4200
4201        let has_messages = self.active_thread_has_messages(cx);
4202        let is_dismissed = self
4203            .agent_layout_onboarding_dismissed
4204            .load(Ordering::Acquire);
4205
4206        if is_dismissed || has_messages {
4207            return false;
4208        }
4209
4210        match &self.active_view {
4211            ActiveView::Uninitialized | ActiveView::Configuration => false,
4212            ActiveView::AgentThread { .. } => {
4213                let existing_user = self
4214                    .new_user_onboarding_upsell_dismissed
4215                    .load(Ordering::Acquire);
4216                existing_user
4217            }
4218        }
4219    }
4220
4221    fn render_agent_layout_onboarding(
4222        &self,
4223        _window: &mut Window,
4224        cx: &mut Context<Self>,
4225    ) -> Option<impl IntoElement> {
4226        if !self.should_render_agent_layout_onboarding(cx) {
4227            return None;
4228        }
4229
4230        Some(div().child(self.agent_layout_onboarding.clone()))
4231    }
4232
4233    fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
4234        self.agent_layout_onboarding_dismissed
4235            .store(true, Ordering::Release);
4236        AgentLayoutOnboarding::set_dismissed(true, cx);
4237        cx.notify();
4238    }
4239
4240    fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
4241        self.new_user_onboarding_upsell_dismissed
4242            .store(true, Ordering::Release);
4243        OnboardingUpsell::set_dismissed(true, cx);
4244        self.dismiss_agent_layout_onboarding(cx);
4245        cx.notify();
4246    }
4247
4248    fn should_render_new_user_onboarding(&mut self, cx: &mut Context<Self>) -> bool {
4249        if self
4250            .new_user_onboarding_upsell_dismissed
4251            .load(Ordering::Acquire)
4252        {
4253            return false;
4254        }
4255
4256        let user_store = self.user_store.read(cx);
4257
4258        if user_store.plan().is_some_and(|plan| plan == Plan::ZedPro)
4259            && user_store
4260                .subscription_period()
4261                .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
4262                .is_some_and(|date| date < chrono::Utc::now())
4263        {
4264            if !self
4265                .new_user_onboarding_upsell_dismissed
4266                .load(Ordering::Acquire)
4267            {
4268                self.dismiss_ai_onboarding(cx);
4269            }
4270            return false;
4271        }
4272
4273        let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
4274            .visible_providers()
4275            .iter()
4276            .any(|provider| {
4277                provider.is_authenticated(cx)
4278                    && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
4279            });
4280
4281        match &self.active_view {
4282            ActiveView::Uninitialized | ActiveView::Configuration => false,
4283            ActiveView::AgentThread {
4284                conversation_view, ..
4285            } if conversation_view.read(cx).as_native_thread(cx).is_none() => false,
4286            ActiveView::AgentThread { .. } => {
4287                let history_is_empty = ThreadStore::global(cx).read(cx).is_empty();
4288                history_is_empty || !has_configured_non_zed_providers
4289            }
4290        }
4291    }
4292
4293    fn render_new_user_onboarding(
4294        &mut self,
4295        _window: &mut Window,
4296        cx: &mut Context<Self>,
4297    ) -> Option<impl IntoElement> {
4298        if !self.should_render_new_user_onboarding(cx) {
4299            return None;
4300        }
4301
4302        Some(
4303            div()
4304                .bg(cx.theme().colors().editor_background)
4305                .child(self.new_user_onboarding.clone()),
4306        )
4307    }
4308
4309    fn render_trial_end_upsell(
4310        &self,
4311        _window: &mut Window,
4312        cx: &mut Context<Self>,
4313    ) -> Option<impl IntoElement> {
4314        if !self.should_render_trial_end_upsell(cx) {
4315            return None;
4316        }
4317
4318        Some(
4319            v_flex()
4320                .absolute()
4321                .inset_0()
4322                .size_full()
4323                .bg(cx.theme().colors().panel_background)
4324                .opacity(0.85)
4325                .block_mouse_except_scroll()
4326                .child(EndTrialUpsell::new(Arc::new({
4327                    let this = cx.entity();
4328                    move |_, cx| {
4329                        this.update(cx, |_this, cx| {
4330                            TrialEndUpsell::set_dismissed(true, cx);
4331                            cx.notify();
4332                        });
4333                    }
4334                }))),
4335        )
4336    }
4337
4338    fn render_drag_target(&self, cx: &Context<Self>) -> Div {
4339        let is_local = self.project.read(cx).is_local();
4340        div()
4341            .invisible()
4342            .absolute()
4343            .top_0()
4344            .right_0()
4345            .bottom_0()
4346            .left_0()
4347            .bg(cx.theme().colors().drop_target_background)
4348            .drag_over::<DraggedTab>(|this, _, _, _| this.visible())
4349            .drag_over::<DraggedSelection>(|this, _, _, _| this.visible())
4350            .when(is_local, |this| {
4351                this.drag_over::<ExternalPaths>(|this, _, _, _| this.visible())
4352            })
4353            .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
4354                let item = tab.pane.read(cx).item_for_index(tab.ix);
4355                let project_paths = item
4356                    .and_then(|item| item.project_path(cx))
4357                    .into_iter()
4358                    .collect::<Vec<_>>();
4359                this.handle_drop(project_paths, vec![], window, cx);
4360            }))
4361            .on_drop(
4362                cx.listener(move |this, selection: &DraggedSelection, window, cx| {
4363                    let project_paths = selection
4364                        .items()
4365                        .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
4366                        .collect::<Vec<_>>();
4367                    this.handle_drop(project_paths, vec![], window, cx);
4368                }),
4369            )
4370            .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
4371                let tasks = paths
4372                    .paths()
4373                    .iter()
4374                    .map(|path| {
4375                        Workspace::project_path_for_path(this.project.clone(), path, false, cx)
4376                    })
4377                    .collect::<Vec<_>>();
4378                cx.spawn_in(window, async move |this, cx| {
4379                    let mut paths = vec![];
4380                    let mut added_worktrees = vec![];
4381                    let opened_paths = futures::future::join_all(tasks).await;
4382                    for entry in opened_paths {
4383                        if let Some((worktree, project_path)) = entry.log_err() {
4384                            added_worktrees.push(worktree);
4385                            paths.push(project_path);
4386                        }
4387                    }
4388                    this.update_in(cx, |this, window, cx| {
4389                        this.handle_drop(paths, added_worktrees, window, cx);
4390                    })
4391                    .ok();
4392                })
4393                .detach();
4394            }))
4395    }
4396
4397    fn handle_drop(
4398        &mut self,
4399        paths: Vec<ProjectPath>,
4400        added_worktrees: Vec<Entity<Worktree>>,
4401        window: &mut Window,
4402        cx: &mut Context<Self>,
4403    ) {
4404        match &self.active_view {
4405            ActiveView::AgentThread { conversation_view } => {
4406                conversation_view.update(cx, |conversation_view, cx| {
4407                    conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
4408                });
4409            }
4410            ActiveView::Uninitialized | ActiveView::Configuration => {}
4411        }
4412    }
4413
4414    fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
4415        if !self.show_trust_workspace_message {
4416            return None;
4417        }
4418
4419        let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
4420
4421        Some(
4422            Callout::new()
4423                .icon(IconName::Warning)
4424                .severity(Severity::Warning)
4425                .border_position(ui::BorderPosition::Bottom)
4426                .title("You're in Restricted Mode")
4427                .description(description)
4428                .actions_slot(
4429                    Button::new("open-trust-modal", "Configure Project Trust")
4430                        .label_size(LabelSize::Small)
4431                        .style(ButtonStyle::Outlined)
4432                        .on_click({
4433                            cx.listener(move |this, _, window, cx| {
4434                                this.workspace
4435                                    .update(cx, |workspace, cx| {
4436                                        workspace
4437                                            .show_worktree_trust_security_modal(true, window, cx)
4438                                    })
4439                                    .log_err();
4440                            })
4441                        }),
4442                ),
4443        )
4444    }
4445
4446    fn key_context(&self) -> KeyContext {
4447        let mut key_context = KeyContext::new_with_defaults();
4448        key_context.add("AgentPanel");
4449        match &self.active_view {
4450            ActiveView::AgentThread { .. } => key_context.add("acp_thread"),
4451            ActiveView::Uninitialized | ActiveView::Configuration => {}
4452        }
4453        key_context
4454    }
4455}
4456
4457impl Render for AgentPanel {
4458    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4459        // WARNING: Changes to this element hierarchy can have
4460        // non-obvious implications to the layout of children.
4461        //
4462        // If you need to change it, please confirm:
4463        // - The message editor expands (cmd-option-esc) correctly
4464        // - When expanded, the buttons at the bottom of the panel are displayed correctly
4465        // - Font size works as expected and can be changed with cmd-+/cmd-
4466        // - Scrolling in all views works as expected
4467        // - Files can be dropped into the panel
4468        let content = v_flex()
4469            .relative()
4470            .size_full()
4471            .justify_between()
4472            .key_context(self.key_context())
4473            .on_action(cx.listener(|this, action: &NewThread, window, cx| {
4474                this.new_thread(action, window, cx);
4475            }))
4476            .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
4477                this.open_configuration(window, cx);
4478            }))
4479            .on_action(cx.listener(Self::open_active_thread_as_markdown))
4480            .on_action(cx.listener(Self::deploy_rules_library))
4481            .on_action(cx.listener(Self::go_back))
4482            .on_action(cx.listener(Self::toggle_options_menu))
4483            .on_action(cx.listener(Self::increase_font_size))
4484            .on_action(cx.listener(Self::decrease_font_size))
4485            .on_action(cx.listener(Self::reset_font_size))
4486            .on_action(cx.listener(Self::toggle_zoom))
4487            .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
4488                if let Some(conversation_view) = this.active_conversation_view() {
4489                    conversation_view.update(cx, |conversation_view, cx| {
4490                        conversation_view.reauthenticate(window, cx)
4491                    })
4492                }
4493            }))
4494            .child(self.render_toolbar(window, cx))
4495            .children(self.render_workspace_trust_message(cx))
4496            .children(self.render_new_user_onboarding(window, cx))
4497            .children(self.render_agent_layout_onboarding(window, cx))
4498            .map(|parent| match &self.active_view {
4499                ActiveView::Uninitialized => parent,
4500                ActiveView::AgentThread {
4501                    conversation_view, ..
4502                } => parent
4503                    .child(conversation_view.clone())
4504                    .child(self.render_drag_target(cx)),
4505                ActiveView::Configuration => parent.children(self.configuration.clone()),
4506            })
4507            .children(self.render_worktree_creation_status(cx))
4508            .children(self.render_trial_end_upsell(window, cx));
4509
4510        match self.active_view.which_font_size_used() {
4511            WhichFontSize::AgentFont => {
4512                WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
4513                    .size_full()
4514                    .child(content)
4515                    .into_any()
4516            }
4517            _ => content.into_any(),
4518        }
4519    }
4520}
4521
4522struct PromptLibraryInlineAssist {
4523    workspace: WeakEntity<Workspace>,
4524}
4525
4526impl PromptLibraryInlineAssist {
4527    pub fn new(workspace: WeakEntity<Workspace>) -> Self {
4528        Self { workspace }
4529    }
4530}
4531
4532impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
4533    fn assist(
4534        &self,
4535        prompt_editor: &Entity<Editor>,
4536        initial_prompt: Option<String>,
4537        window: &mut Window,
4538        cx: &mut Context<RulesLibrary>,
4539    ) {
4540        InlineAssistant::update_global(cx, |assistant, cx| {
4541            let Some(workspace) = self.workspace.upgrade() else {
4542                return;
4543            };
4544            let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
4545                return;
4546            };
4547            let history = panel
4548                .read(cx)
4549                .connection_store()
4550                .read(cx)
4551                .entry(&crate::Agent::NativeAgent)
4552                .and_then(|s| s.read(cx).history())
4553                .map(|h| h.downgrade());
4554            let project = workspace.read(cx).project().downgrade();
4555            let panel = panel.read(cx);
4556            let thread_store = panel.thread_store().clone();
4557            assistant.assist(
4558                prompt_editor,
4559                self.workspace.clone(),
4560                project,
4561                thread_store,
4562                None,
4563                history,
4564                initial_prompt,
4565                window,
4566                cx,
4567            );
4568        })
4569    }
4570
4571    fn focus_agent_panel(
4572        &self,
4573        workspace: &mut Workspace,
4574        window: &mut Window,
4575        cx: &mut Context<Workspace>,
4576    ) -> bool {
4577        workspace.focus_panel::<AgentPanel>(window, cx).is_some()
4578    }
4579}
4580
4581struct OnboardingUpsell;
4582
4583impl Dismissable for OnboardingUpsell {
4584    const KEY: &'static str = "dismissed-trial-upsell";
4585}
4586
4587struct AgentLayoutOnboarding;
4588
4589impl Dismissable for AgentLayoutOnboarding {
4590    const KEY: &'static str = "dismissed-agent-layout-onboarding";
4591}
4592
4593struct TrialEndUpsell;
4594
4595impl Dismissable for TrialEndUpsell {
4596    const KEY: &'static str = "dismissed-trial-end-upsell";
4597}
4598
4599/// Test-only helper methods
4600#[cfg(any(test, feature = "test-support"))]
4601impl AgentPanel {
4602    pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
4603        Self::new(workspace, None, window, cx)
4604    }
4605
4606    /// Opens an external thread using an arbitrary AgentServer.
4607    ///
4608    /// This is a test-only helper that allows visual tests and integration tests
4609    /// to inject a stub server without modifying production code paths.
4610    /// Not compiled into production builds.
4611    pub fn open_external_thread_with_server(
4612        &mut self,
4613        server: Rc<dyn AgentServer>,
4614        window: &mut Window,
4615        cx: &mut Context<Self>,
4616    ) {
4617        let workspace = self.workspace.clone();
4618        let project = self.project.clone();
4619
4620        let ext_agent = Agent::Custom {
4621            id: server.agent_id(),
4622        };
4623
4624        let conversation_view = self.create_agent_thread(
4625            server, None, None, None, None, workspace, project, ext_agent, window, cx,
4626        );
4627        self.set_active_view(
4628            ActiveView::AgentThread { conversation_view },
4629            true,
4630            window,
4631            cx,
4632        );
4633    }
4634
4635    /// Returns the currently active thread view, if any.
4636    ///
4637    /// This is a test-only accessor that exposes the private `active_thread_view()`
4638    /// method for test assertions. Not compiled into production builds.
4639    pub fn active_thread_view_for_tests(&self) -> Option<&Entity<ConversationView>> {
4640        self.active_conversation_view()
4641    }
4642
4643    /// Sets the start_thread_in value directly, bypassing validation.
4644    ///
4645    /// This is a test-only helper for visual tests that need to show specific
4646    /// start_thread_in states without requiring a real git repository.
4647    pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context<Self>) {
4648        self.start_thread_in = target;
4649        cx.notify();
4650    }
4651
4652    /// Returns the current worktree creation status.
4653    ///
4654    /// This is a test-only helper for visual tests.
4655    pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> {
4656        self.worktree_creation_status.as_ref().map(|(_, s)| s)
4657    }
4658
4659    /// Sets the worktree creation status directly, associating it with the
4660    /// currently active conversation view.
4661    ///
4662    /// This is a test-only helper for visual tests that need to show the
4663    /// "Creating worktree…" spinner or error banners.
4664    pub fn set_worktree_creation_status_for_tests(
4665        &mut self,
4666        status: Option<WorktreeCreationStatus>,
4667        cx: &mut Context<Self>,
4668    ) {
4669        self.worktree_creation_status = status.map(|s| {
4670            let view_id = self
4671                .active_conversation_view()
4672                .map(|v| v.entity_id())
4673                .unwrap_or_else(|| EntityId::from(0u64));
4674            (view_id, s)
4675        });
4676        cx.notify();
4677    }
4678
4679    /// Opens the start_thread_in selector popover menu.
4680    ///
4681    /// This is a test-only helper for visual tests.
4682    pub fn open_start_thread_in_menu_for_tests(
4683        &mut self,
4684        window: &mut Window,
4685        cx: &mut Context<Self>,
4686    ) {
4687        self.start_thread_in_menu_handle.show(window, cx);
4688    }
4689
4690    /// Dismisses the start_thread_in dropdown menu.
4691    ///
4692    /// This is a test-only helper for visual tests.
4693    pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
4694        self.start_thread_in_menu_handle.hide(cx);
4695    }
4696}
4697
4698#[cfg(test)]
4699mod tests {
4700    use super::*;
4701    use crate::conversation_view::tests::{StubAgentServer, init_test};
4702    use crate::test_support::{
4703        active_session_id, open_thread_with_connection, open_thread_with_custom_connection,
4704        send_message,
4705    };
4706    use acp_thread::{StubAgentConnection, ThreadStatus};
4707    use agent_servers::CODEX_ID;
4708    use feature_flags::FeatureFlagAppExt;
4709    use fs::FakeFs;
4710    use gpui::{TestAppContext, VisualTestContext};
4711    use project::Project;
4712    use serde_json::json;
4713    use std::path::Path;
4714    use std::time::Instant;
4715    use workspace::MultiWorkspace;
4716
4717    #[gpui::test]
4718    async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
4719        init_test(cx);
4720        cx.update(|cx| {
4721            agent::ThreadStore::init_global(cx);
4722            language_model::LanguageModelRegistry::test(cx);
4723        });
4724
4725        // Create a MultiWorkspace window with two workspaces.
4726        let fs = FakeFs::new(cx.executor());
4727        let project_a = Project::test(fs.clone(), [], cx).await;
4728        let project_b = Project::test(fs, [], cx).await;
4729
4730        let multi_workspace =
4731            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
4732
4733        let workspace_a = multi_workspace
4734            .read_with(cx, |multi_workspace, _cx| {
4735                multi_workspace.workspace().clone()
4736            })
4737            .unwrap();
4738
4739        let workspace_b = multi_workspace
4740            .update(cx, |multi_workspace, window, cx| {
4741                multi_workspace.test_add_workspace(project_b.clone(), window, cx)
4742            })
4743            .unwrap();
4744
4745        workspace_a.update(cx, |workspace, _cx| {
4746            workspace.set_random_database_id();
4747        });
4748        workspace_b.update(cx, |workspace, _cx| {
4749            workspace.set_random_database_id();
4750        });
4751
4752        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4753
4754        // Set up workspace A: with an active thread.
4755        let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
4756            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4757        });
4758
4759        panel_a.update_in(cx, |panel, window, cx| {
4760            panel.open_external_thread_with_server(
4761                Rc::new(StubAgentServer::default_response()),
4762                window,
4763                cx,
4764            );
4765        });
4766
4767        cx.run_until_parked();
4768
4769        panel_a.read_with(cx, |panel, cx| {
4770            assert!(
4771                panel.active_agent_thread(cx).is_some(),
4772                "workspace A should have an active thread after connection"
4773            );
4774        });
4775
4776        send_message(&panel_a, cx);
4777
4778        let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
4779
4780        // Set up workspace B: ClaudeCode, no active thread.
4781        let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
4782            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4783        });
4784
4785        panel_b.update(cx, |panel, _cx| {
4786            panel.selected_agent = Agent::Custom {
4787                id: "claude-acp".into(),
4788            };
4789        });
4790
4791        // Serialize both panels.
4792        panel_a.update(cx, |panel, cx| panel.serialize(cx));
4793        panel_b.update(cx, |panel, cx| panel.serialize(cx));
4794        cx.run_until_parked();
4795
4796        // Load fresh panels for each workspace and verify independent state.
4797        let async_cx = cx.update(|window, cx| window.to_async(cx));
4798        let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
4799            .await
4800            .expect("panel A load should succeed");
4801        cx.run_until_parked();
4802
4803        let async_cx = cx.update(|window, cx| window.to_async(cx));
4804        let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
4805            .await
4806            .expect("panel B load should succeed");
4807        cx.run_until_parked();
4808
4809        // Workspace A should restore its thread and agent type
4810        loaded_a.read_with(cx, |panel, _cx| {
4811            assert_eq!(
4812                panel.selected_agent, agent_type_a,
4813                "workspace A agent type should be restored"
4814            );
4815            assert!(
4816                panel.active_conversation_view().is_some(),
4817                "workspace A should have its active thread restored"
4818            );
4819        });
4820
4821        // Workspace B should restore its own agent type, with no thread
4822        loaded_b.read_with(cx, |panel, _cx| {
4823            assert_eq!(
4824                panel.selected_agent,
4825                Agent::Custom {
4826                    id: "claude-acp".into()
4827                },
4828                "workspace B agent type should be restored"
4829            );
4830            assert!(
4831                panel.active_conversation_view().is_none(),
4832                "workspace B should have no active thread"
4833            );
4834        });
4835    }
4836
4837    #[gpui::test]
4838    async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
4839        init_test(cx);
4840        cx.update(|cx| {
4841            agent::ThreadStore::init_global(cx);
4842            language_model::LanguageModelRegistry::test(cx);
4843        });
4844
4845        let fs = FakeFs::new(cx.executor());
4846        let project = Project::test(fs, [], cx).await;
4847
4848        let multi_workspace =
4849            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4850
4851        let workspace = multi_workspace
4852            .read_with(cx, |multi_workspace, _cx| {
4853                multi_workspace.workspace().clone()
4854            })
4855            .unwrap();
4856
4857        workspace.update(cx, |workspace, _cx| {
4858            workspace.set_random_database_id();
4859        });
4860
4861        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
4862
4863        let panel = workspace.update_in(cx, |workspace, window, cx| {
4864            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
4865        });
4866
4867        panel.update_in(cx, |panel, window, cx| {
4868            panel.open_external_thread_with_server(
4869                Rc::new(StubAgentServer::default_response()),
4870                window,
4871                cx,
4872            );
4873        });
4874
4875        cx.run_until_parked();
4876
4877        panel.read_with(cx, |panel, cx| {
4878            assert!(
4879                panel.active_agent_thread(cx).is_some(),
4880                "should have an active thread after connection"
4881            );
4882        });
4883
4884        // Serialize without ever sending a message, so no thread metadata exists.
4885        panel.update(cx, |panel, cx| panel.serialize(cx));
4886        cx.run_until_parked();
4887
4888        let async_cx = cx.update(|window, cx| window.to_async(cx));
4889        let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
4890            .await
4891            .expect("panel load should succeed");
4892        cx.run_until_parked();
4893
4894        loaded.read_with(cx, |panel, _cx| {
4895            assert!(
4896                panel.active_conversation_view().is_none(),
4897                "thread without metadata should not be restored"
4898            );
4899        });
4900    }
4901
4902    /// Extracts the text from a Text content block, panicking if it's not Text.
4903    fn expect_text_block(block: &acp::ContentBlock) -> &str {
4904        match block {
4905            acp::ContentBlock::Text(t) => t.text.as_str(),
4906            other => panic!("expected Text block, got {:?}", other),
4907        }
4908    }
4909
4910    /// Extracts the (text_content, uri) from a Resource content block, panicking
4911    /// if it's not a TextResourceContents resource.
4912    fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) {
4913        match block {
4914            acp::ContentBlock::Resource(r) => match &r.resource {
4915                acp::EmbeddedResourceResource::TextResourceContents(t) => {
4916                    (t.text.as_str(), t.uri.as_str())
4917                }
4918                other => panic!("expected TextResourceContents, got {:?}", other),
4919            },
4920            other => panic!("expected Resource block, got {:?}", other),
4921        }
4922    }
4923
4924    #[test]
4925    fn test_build_conflict_resolution_prompt_single_conflict() {
4926        let conflicts = vec![ConflictContent {
4927            file_path: "src/main.rs".to_string(),
4928            conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature"
4929                .to_string(),
4930            ours_branch_name: "HEAD".to_string(),
4931            theirs_branch_name: "feature".to_string(),
4932        }];
4933
4934        let blocks = build_conflict_resolution_prompt(&conflicts);
4935        // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict
4936        assert_eq!(
4937            blocks.len(),
4938            4,
4939            "expected 2 text + 1 resource link + 1 resource block"
4940        );
4941
4942        let intro_text = expect_text_block(&blocks[0]);
4943        assert!(
4944            intro_text.contains("Please resolve the following merge conflict in"),
4945            "prompt should include single-conflict intro text"
4946        );
4947
4948        match &blocks[1] {
4949            acp::ContentBlock::ResourceLink(link) => {
4950                assert!(
4951                    link.uri.contains("file://"),
4952                    "resource link URI should use file scheme"
4953                );
4954                assert!(
4955                    link.uri.contains("main.rs"),
4956                    "resource link URI should reference file path"
4957                );
4958            }
4959            other => panic!("expected ResourceLink block, got {:?}", other),
4960        }
4961
4962        let body_text = expect_text_block(&blocks[2]);
4963        assert!(
4964            body_text.contains("`HEAD` (ours)"),
4965            "prompt should mention ours branch"
4966        );
4967        assert!(
4968            body_text.contains("`feature` (theirs)"),
4969            "prompt should mention theirs branch"
4970        );
4971        assert!(
4972            body_text.contains("editing the file directly"),
4973            "prompt should instruct the agent to edit the file"
4974        );
4975
4976        let (resource_text, resource_uri) = expect_resource_block(&blocks[3]);
4977        assert!(
4978            resource_text.contains("<<<<<<< HEAD"),
4979            "resource should contain the conflict text"
4980        );
4981        assert!(
4982            resource_uri.contains("merge-conflict"),
4983            "resource URI should use the merge-conflict scheme"
4984        );
4985        assert!(
4986            resource_uri.contains("main.rs"),
4987            "resource URI should reference the file path"
4988        );
4989    }
4990
4991    #[test]
4992    fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() {
4993        let conflicts = vec![
4994            ConflictContent {
4995                file_path: "src/lib.rs".to_string(),
4996                conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev"
4997                    .to_string(),
4998                ours_branch_name: "main".to_string(),
4999                theirs_branch_name: "dev".to_string(),
5000            },
5001            ConflictContent {
5002                file_path: "src/lib.rs".to_string(),
5003                conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev"
5004                    .to_string(),
5005                ours_branch_name: "main".to_string(),
5006                theirs_branch_name: "dev".to_string(),
5007            },
5008        ];
5009
5010        let blocks = build_conflict_resolution_prompt(&conflicts);
5011        // 1 Text instruction + 2 Resource blocks
5012        assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5013
5014        let text = expect_text_block(&blocks[0]);
5015        assert!(
5016            text.contains("all 2 merge conflicts"),
5017            "prompt should mention the total count"
5018        );
5019        assert!(
5020            text.contains("`main` (ours)"),
5021            "prompt should mention ours branch"
5022        );
5023        assert!(
5024            text.contains("`dev` (theirs)"),
5025            "prompt should mention theirs branch"
5026        );
5027        // Single file, so "file" not "files"
5028        assert!(
5029            text.contains("file directly"),
5030            "single file should use singular 'file'"
5031        );
5032
5033        let (resource_a, _) = expect_resource_block(&blocks[1]);
5034        let (resource_b, _) = expect_resource_block(&blocks[2]);
5035        assert!(
5036            resource_a.contains("fn a()"),
5037            "first resource should contain first conflict"
5038        );
5039        assert!(
5040            resource_b.contains("fn b()"),
5041            "second resource should contain second conflict"
5042        );
5043    }
5044
5045    #[test]
5046    fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() {
5047        let conflicts = vec![
5048            ConflictContent {
5049                file_path: "src/a.rs".to_string(),
5050                conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(),
5051                ours_branch_name: "main".to_string(),
5052                theirs_branch_name: "dev".to_string(),
5053            },
5054            ConflictContent {
5055                file_path: "src/b.rs".to_string(),
5056                conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(),
5057                ours_branch_name: "main".to_string(),
5058                theirs_branch_name: "dev".to_string(),
5059            },
5060        ];
5061
5062        let blocks = build_conflict_resolution_prompt(&conflicts);
5063        // 1 Text instruction + 2 Resource blocks
5064        assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks");
5065
5066        let text = expect_text_block(&blocks[0]);
5067        assert!(
5068            text.contains("files directly"),
5069            "multiple files should use plural 'files'"
5070        );
5071
5072        let (_, uri_a) = expect_resource_block(&blocks[1]);
5073        let (_, uri_b) = expect_resource_block(&blocks[2]);
5074        assert!(
5075            uri_a.contains("a.rs"),
5076            "first resource URI should reference a.rs"
5077        );
5078        assert!(
5079            uri_b.contains("b.rs"),
5080            "second resource URI should reference b.rs"
5081        );
5082    }
5083
5084    #[test]
5085    fn test_build_conflicted_files_resolution_prompt_file_paths_only() {
5086        let file_paths = vec![
5087            "src/main.rs".to_string(),
5088            "src/lib.rs".to_string(),
5089            "tests/integration.rs".to_string(),
5090        ];
5091
5092        let blocks = build_conflicted_files_resolution_prompt(&file_paths);
5093        // 1 instruction Text block + (ResourceLink + newline Text) per file
5094        assert_eq!(
5095            blocks.len(),
5096            1 + (file_paths.len() * 2),
5097            "expected instruction text plus resource links and separators"
5098        );
5099
5100        let text = expect_text_block(&blocks[0]);
5101        assert!(
5102            text.contains("unresolved merge conflicts"),
5103            "prompt should describe the task"
5104        );
5105        assert!(
5106            text.contains("conflict markers"),
5107            "prompt should mention conflict markers"
5108        );
5109
5110        for (index, path) in file_paths.iter().enumerate() {
5111            let link_index = 1 + (index * 2);
5112            let newline_index = link_index + 1;
5113
5114            match &blocks[link_index] {
5115                acp::ContentBlock::ResourceLink(link) => {
5116                    assert!(
5117                        link.uri.contains("file://"),
5118                        "resource link URI should use file scheme"
5119                    );
5120                    assert!(
5121                        link.uri.contains(path),
5122                        "resource link URI should reference file path: {path}"
5123                    );
5124                }
5125                other => panic!(
5126                    "expected ResourceLink block at index {}, got {:?}",
5127                    link_index, other
5128                ),
5129            }
5130
5131            let separator = expect_text_block(&blocks[newline_index]);
5132            assert_eq!(
5133                separator, "\n",
5134                "expected newline separator after each file"
5135            );
5136        }
5137    }
5138
5139    #[test]
5140    fn test_build_conflict_resolution_prompt_empty_conflicts() {
5141        let blocks = build_conflict_resolution_prompt(&[]);
5142        assert!(
5143            blocks.is_empty(),
5144            "empty conflicts should produce no blocks, got {} blocks",
5145            blocks.len()
5146        );
5147    }
5148
5149    #[test]
5150    fn test_build_conflicted_files_resolution_prompt_empty_paths() {
5151        let blocks = build_conflicted_files_resolution_prompt(&[]);
5152        assert!(
5153            blocks.is_empty(),
5154            "empty paths should produce no blocks, got {} blocks",
5155            blocks.len()
5156        );
5157    }
5158
5159    #[test]
5160    fn test_conflict_resource_block_structure() {
5161        let conflict = ConflictContent {
5162            file_path: "src/utils.rs".to_string(),
5163            conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(),
5164            ours_branch_name: "HEAD".to_string(),
5165            theirs_branch_name: "branch".to_string(),
5166        };
5167
5168        let block = conflict_resource_block(&conflict);
5169        let (text, uri) = expect_resource_block(&block);
5170
5171        assert_eq!(
5172            text, conflict.conflict_text,
5173            "resource text should be the raw conflict"
5174        );
5175        assert!(
5176            uri.starts_with("zed:///agent/merge-conflict"),
5177            "URI should use the zed merge-conflict scheme, got: {uri}"
5178        );
5179        assert!(uri.contains("utils.rs"), "URI should encode the file path");
5180    }
5181
5182    fn open_generating_thread_with_loadable_connection(
5183        panel: &Entity<AgentPanel>,
5184        connection: &StubAgentConnection,
5185        cx: &mut VisualTestContext,
5186    ) -> acp::SessionId {
5187        open_thread_with_custom_connection(panel, connection.clone(), cx);
5188        let session_id = active_session_id(panel, cx);
5189        send_message(panel, cx);
5190        cx.update(|_, cx| {
5191            connection.send_update(
5192                session_id.clone(),
5193                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("done".into())),
5194                cx,
5195            );
5196        });
5197        cx.run_until_parked();
5198        session_id
5199    }
5200
5201    fn open_idle_thread_with_non_loadable_connection(
5202        panel: &Entity<AgentPanel>,
5203        connection: &StubAgentConnection,
5204        cx: &mut VisualTestContext,
5205    ) -> acp::SessionId {
5206        open_thread_with_custom_connection(panel, connection.clone(), cx);
5207        let session_id = active_session_id(panel, cx);
5208
5209        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5210            acp::ContentChunk::new("done".into()),
5211        )]);
5212        send_message(panel, cx);
5213
5214        session_id
5215    }
5216
5217    async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
5218        init_test(cx);
5219        cx.update(|cx| {
5220            agent::ThreadStore::init_global(cx);
5221            language_model::LanguageModelRegistry::test(cx);
5222        });
5223
5224        let fs = FakeFs::new(cx.executor());
5225        let project = Project::test(fs.clone(), [], cx).await;
5226
5227        let multi_workspace =
5228            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5229
5230        let workspace = multi_workspace
5231            .read_with(cx, |mw, _cx| mw.workspace().clone())
5232            .unwrap();
5233
5234        let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
5235
5236        let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
5237            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
5238        });
5239
5240        (panel, cx)
5241    }
5242
5243    #[gpui::test]
5244    async fn test_empty_draft_thread_not_retained_when_navigating_away(cx: &mut TestAppContext) {
5245        let (panel, mut cx) = setup_panel(cx).await;
5246
5247        let connection_a = StubAgentConnection::new();
5248        open_thread_with_connection(&panel, connection_a, &mut cx);
5249        let session_id_a = active_session_id(&panel, &cx);
5250
5251        panel.read_with(&cx, |panel, cx| {
5252            let thread = panel.active_agent_thread(cx).unwrap();
5253            assert!(
5254                thread.read(cx).entries().is_empty(),
5255                "newly opened draft thread should have no entries"
5256            );
5257            assert!(panel.background_threads.is_empty());
5258        });
5259
5260        let connection_b = StubAgentConnection::new();
5261        open_thread_with_connection(&panel, connection_b, &mut cx);
5262
5263        panel.read_with(&cx, |panel, _cx| {
5264            assert!(
5265                panel.background_threads.is_empty(),
5266                "empty draft thread should not be retained in background_threads"
5267            );
5268            assert!(
5269                !panel.background_threads.contains_key(&session_id_a),
5270                "empty draft thread should not be keyed in background_threads"
5271            );
5272        });
5273    }
5274
5275    #[gpui::test]
5276    async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5277        let (panel, mut cx) = setup_panel(cx).await;
5278
5279        let connection_a = StubAgentConnection::new();
5280        open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5281        send_message(&panel, &mut cx);
5282
5283        let session_id_a = active_session_id(&panel, &cx);
5284
5285        // Send a chunk to keep thread A generating (don't end the turn).
5286        cx.update(|_, cx| {
5287            connection_a.send_update(
5288                session_id_a.clone(),
5289                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5290                cx,
5291            );
5292        });
5293        cx.run_until_parked();
5294
5295        // Verify thread A is generating.
5296        panel.read_with(&cx, |panel, cx| {
5297            let thread = panel.active_agent_thread(cx).unwrap();
5298            assert_eq!(thread.read(cx).status(), ThreadStatus::Generating);
5299            assert!(panel.background_threads.is_empty());
5300        });
5301
5302        // Open a new thread B — thread A should be retained in background.
5303        let connection_b = StubAgentConnection::new();
5304        open_thread_with_connection(&panel, connection_b, &mut cx);
5305
5306        panel.read_with(&cx, |panel, _cx| {
5307            assert_eq!(
5308                panel.background_threads.len(),
5309                1,
5310                "Running thread A should be retained in background_views"
5311            );
5312            assert!(
5313                panel.background_threads.contains_key(&session_id_a),
5314                "Background view should be keyed by thread A's session ID"
5315            );
5316        });
5317    }
5318
5319    #[gpui::test]
5320    async fn test_idle_non_loadable_thread_retained_when_navigating_away(cx: &mut TestAppContext) {
5321        let (panel, mut cx) = setup_panel(cx).await;
5322
5323        let connection_a = StubAgentConnection::new();
5324        connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
5325            acp::ContentChunk::new("Response".into()),
5326        )]);
5327        open_thread_with_connection(&panel, connection_a, &mut cx);
5328        send_message(&panel, &mut cx);
5329
5330        let weak_view_a = panel.read_with(&cx, |panel, _cx| {
5331            panel.active_conversation_view().unwrap().downgrade()
5332        });
5333        let session_id_a = active_session_id(&panel, &cx);
5334
5335        // Thread A should be idle (auto-completed via set_next_prompt_updates).
5336        panel.read_with(&cx, |panel, cx| {
5337            let thread = panel.active_agent_thread(cx).unwrap();
5338            assert_eq!(thread.read(cx).status(), ThreadStatus::Idle);
5339        });
5340
5341        // Open a new thread B — thread A should be retained because it is not loadable.
5342        let connection_b = StubAgentConnection::new();
5343        open_thread_with_connection(&panel, connection_b, &mut cx);
5344
5345        panel.read_with(&cx, |panel, _cx| {
5346            assert_eq!(
5347                panel.background_threads.len(),
5348                1,
5349                "Idle non-loadable thread A should be retained in background_views"
5350            );
5351            assert!(
5352                panel.background_threads.contains_key(&session_id_a),
5353                "Background view should be keyed by thread A's session ID"
5354            );
5355        });
5356
5357        assert!(
5358            weak_view_a.upgrade().is_some(),
5359            "Idle non-loadable ConnectionView should still be retained"
5360        );
5361    }
5362
5363    #[gpui::test]
5364    async fn test_background_thread_promoted_via_load(cx: &mut TestAppContext) {
5365        let (panel, mut cx) = setup_panel(cx).await;
5366
5367        let connection_a = StubAgentConnection::new();
5368        open_thread_with_connection(&panel, connection_a.clone(), &mut cx);
5369        send_message(&panel, &mut cx);
5370
5371        let session_id_a = active_session_id(&panel, &cx);
5372
5373        // Keep thread A generating.
5374        cx.update(|_, cx| {
5375            connection_a.send_update(
5376                session_id_a.clone(),
5377                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())),
5378                cx,
5379            );
5380        });
5381        cx.run_until_parked();
5382
5383        // Open thread B — thread A goes to background.
5384        let connection_b = StubAgentConnection::new();
5385        open_thread_with_connection(&panel, connection_b, &mut cx);
5386        send_message(&panel, &mut cx);
5387
5388        let session_id_b = active_session_id(&panel, &cx);
5389
5390        panel.read_with(&cx, |panel, _cx| {
5391            assert_eq!(panel.background_threads.len(), 1);
5392            assert!(panel.background_threads.contains_key(&session_id_a));
5393        });
5394
5395        // Load thread A back via load_agent_thread — should promote from background.
5396        panel.update_in(&mut cx, |panel, window, cx| {
5397            panel.load_agent_thread(
5398                panel.selected_agent().expect("selected agent must be set"),
5399                session_id_a.clone(),
5400                None,
5401                None,
5402                true,
5403                window,
5404                cx,
5405            );
5406        });
5407
5408        // Thread A should now be the active view, promoted from background.
5409        let active_session = active_session_id(&panel, &cx);
5410        assert_eq!(
5411            active_session, session_id_a,
5412            "Thread A should be the active thread after promotion"
5413        );
5414
5415        panel.read_with(&cx, |panel, _cx| {
5416            assert!(
5417                !panel.background_threads.contains_key(&session_id_a),
5418                "Promoted thread A should no longer be in background_views"
5419            );
5420            assert!(
5421                panel.background_threads.contains_key(&session_id_b),
5422                "Thread B (idle, non-loadable) should remain retained in background_views"
5423            );
5424        });
5425    }
5426
5427    #[gpui::test]
5428    async fn test_cleanup_background_threads_keeps_five_most_recent_idle_loadable_threads(
5429        cx: &mut TestAppContext,
5430    ) {
5431        let (panel, mut cx) = setup_panel(cx).await;
5432        let connection = StubAgentConnection::new()
5433            .with_supports_load_session(true)
5434            .with_agent_id("loadable-stub".into())
5435            .with_telemetry_id("loadable-stub".into());
5436        let mut session_ids = Vec::new();
5437
5438        for _ in 0..7 {
5439            session_ids.push(open_generating_thread_with_loadable_connection(
5440                &panel,
5441                &connection,
5442                &mut cx,
5443            ));
5444        }
5445
5446        let base_time = Instant::now();
5447
5448        for session_id in session_ids.iter().take(6) {
5449            connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5450        }
5451        cx.run_until_parked();
5452
5453        panel.update(&mut cx, |panel, cx| {
5454            for (index, session_id) in session_ids.iter().take(6).enumerate() {
5455                let conversation_view = panel
5456                    .background_threads
5457                    .get(session_id)
5458                    .expect("background thread should exist")
5459                    .clone();
5460                conversation_view.update(cx, |view, cx| {
5461                    view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5462                });
5463            }
5464            panel.cleanup_background_threads(cx);
5465        });
5466
5467        panel.read_with(&cx, |panel, _cx| {
5468            assert_eq!(
5469                panel.background_threads.len(),
5470                5,
5471                "cleanup should keep at most five idle loadable background threads"
5472            );
5473            assert!(
5474                !panel.background_threads.contains_key(&session_ids[0]),
5475                "oldest idle loadable background thread should be removed"
5476            );
5477            for session_id in &session_ids[1..6] {
5478                assert!(
5479                    panel.background_threads.contains_key(session_id),
5480                    "more recent idle loadable background threads should be retained"
5481                );
5482            }
5483            assert!(
5484                !panel.background_threads.contains_key(&session_ids[6]),
5485                "the active thread should not also be stored as a background thread"
5486            );
5487        });
5488    }
5489
5490    #[gpui::test]
5491    async fn test_cleanup_background_threads_preserves_idle_non_loadable_threads(
5492        cx: &mut TestAppContext,
5493    ) {
5494        let (panel, mut cx) = setup_panel(cx).await;
5495
5496        let non_loadable_connection = StubAgentConnection::new();
5497        let non_loadable_session_id = open_idle_thread_with_non_loadable_connection(
5498            &panel,
5499            &non_loadable_connection,
5500            &mut cx,
5501        );
5502
5503        let loadable_connection = StubAgentConnection::new()
5504            .with_supports_load_session(true)
5505            .with_agent_id("loadable-stub".into())
5506            .with_telemetry_id("loadable-stub".into());
5507        let mut loadable_session_ids = Vec::new();
5508
5509        for _ in 0..7 {
5510            loadable_session_ids.push(open_generating_thread_with_loadable_connection(
5511                &panel,
5512                &loadable_connection,
5513                &mut cx,
5514            ));
5515        }
5516
5517        let base_time = Instant::now();
5518
5519        for session_id in loadable_session_ids.iter().take(6) {
5520            loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn);
5521        }
5522        cx.run_until_parked();
5523
5524        panel.update(&mut cx, |panel, cx| {
5525            for (index, session_id) in loadable_session_ids.iter().take(6).enumerate() {
5526                let conversation_view = panel
5527                    .background_threads
5528                    .get(session_id)
5529                    .expect("background thread should exist")
5530                    .clone();
5531                conversation_view.update(cx, |view, cx| {
5532                    view.set_updated_at(base_time + Duration::from_secs(index as u64), cx);
5533                });
5534            }
5535            panel.cleanup_background_threads(cx);
5536        });
5537
5538        panel.read_with(&cx, |panel, _cx| {
5539            assert_eq!(
5540                panel.background_threads.len(),
5541                6,
5542                "cleanup should keep the non-loadable idle thread in addition to five loadable ones"
5543            );
5544            assert!(
5545                panel
5546                    .background_threads
5547                    .contains_key(&non_loadable_session_id),
5548                "idle non-loadable background threads should not be cleanup candidates"
5549            );
5550            assert!(
5551                !panel
5552                    .background_threads
5553                    .contains_key(&loadable_session_ids[0]),
5554                "oldest idle loadable background thread should still be removed"
5555            );
5556            for session_id in &loadable_session_ids[1..6] {
5557                assert!(
5558                    panel.background_threads.contains_key(session_id),
5559                    "more recent idle loadable background threads should be retained"
5560                );
5561            }
5562            assert!(
5563                !panel
5564                    .background_threads
5565                    .contains_key(&loadable_session_ids[6]),
5566                "the active loadable thread should not also be stored as a background thread"
5567            );
5568        });
5569    }
5570
5571    #[gpui::test]
5572    async fn test_thread_target_local_project(cx: &mut TestAppContext) {
5573        init_test(cx);
5574        cx.update(|cx| {
5575            agent::ThreadStore::init_global(cx);
5576            language_model::LanguageModelRegistry::test(cx);
5577        });
5578
5579        let fs = FakeFs::new(cx.executor());
5580        fs.insert_tree(
5581            "/project",
5582            json!({
5583                ".git": {},
5584                "src": {
5585                    "main.rs": "fn main() {}"
5586                }
5587            }),
5588        )
5589        .await;
5590        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5591
5592        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5593
5594        let multi_workspace =
5595            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5596
5597        let workspace = multi_workspace
5598            .read_with(cx, |multi_workspace, _cx| {
5599                multi_workspace.workspace().clone()
5600            })
5601            .unwrap();
5602
5603        workspace.update(cx, |workspace, _cx| {
5604            workspace.set_random_database_id();
5605        });
5606
5607        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5608
5609        // Wait for the project to discover the git repository.
5610        cx.run_until_parked();
5611
5612        let panel = workspace.update_in(cx, |workspace, window, cx| {
5613            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5614            workspace.add_panel(panel.clone(), window, cx);
5615            panel
5616        });
5617
5618        cx.run_until_parked();
5619
5620        // Default thread target should be LocalProject.
5621        panel.read_with(cx, |panel, _cx| {
5622            assert_eq!(
5623                *panel.start_thread_in(),
5624                StartThreadIn::LocalProject,
5625                "default thread target should be LocalProject"
5626            );
5627        });
5628
5629        // Start a new thread with the default LocalProject target.
5630        // Use StubAgentServer so the thread connects immediately in tests.
5631        panel.update_in(cx, |panel, window, cx| {
5632            panel.open_external_thread_with_server(
5633                Rc::new(StubAgentServer::default_response()),
5634                window,
5635                cx,
5636            );
5637        });
5638
5639        cx.run_until_parked();
5640
5641        // MultiWorkspace should still have exactly one workspace (no worktree created).
5642        multi_workspace
5643            .read_with(cx, |multi_workspace, _cx| {
5644                assert_eq!(
5645                    multi_workspace.workspaces().count(),
5646                    1,
5647                    "LocalProject should not create a new workspace"
5648                );
5649            })
5650            .unwrap();
5651
5652        // The thread should be active in the panel.
5653        panel.read_with(cx, |panel, cx| {
5654            assert!(
5655                panel.active_agent_thread(cx).is_some(),
5656                "a thread should be running in the current workspace"
5657            );
5658        });
5659
5660        // The thread target should still be LocalProject (unchanged).
5661        panel.read_with(cx, |panel, _cx| {
5662            assert_eq!(
5663                *panel.start_thread_in(),
5664                StartThreadIn::LocalProject,
5665                "thread target should remain LocalProject"
5666            );
5667        });
5668
5669        // No worktree creation status should be set.
5670        panel.read_with(cx, |panel, _cx| {
5671            assert!(
5672                panel.worktree_creation_status.is_none(),
5673                "no worktree creation should have occurred"
5674            );
5675        });
5676    }
5677
5678    #[gpui::test]
5679    async fn test_thread_target_does_not_sync_to_external_linked_worktree_with_invalid_branch_target(
5680        cx: &mut TestAppContext,
5681    ) {
5682        use git::repository::Worktree as GitWorktree;
5683
5684        init_test(cx);
5685        cx.update(|cx| {
5686            agent::ThreadStore::init_global(cx);
5687            language_model::LanguageModelRegistry::test(cx);
5688        });
5689
5690        let fs = FakeFs::new(cx.executor());
5691        fs.insert_tree(
5692            "/project",
5693            json!({
5694                ".git": {},
5695                "src": {
5696                    "main.rs": "fn main() {}"
5697                }
5698            }),
5699        )
5700        .await;
5701        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5702        fs.insert_branches(Path::new("/project/.git"), &["main", "feature-worktree"]);
5703
5704        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5705
5706        let multi_workspace =
5707            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5708
5709        let workspace = multi_workspace
5710            .read_with(cx, |multi_workspace, _cx| {
5711                multi_workspace.workspace().clone()
5712            })
5713            .unwrap();
5714
5715        workspace.update(cx, |workspace, _cx| {
5716            workspace.set_random_database_id();
5717        });
5718
5719        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5720
5721        cx.run_until_parked();
5722
5723        let panel = workspace.update_in(cx, |workspace, window, cx| {
5724            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5725            workspace.add_panel(panel.clone(), window, cx);
5726            panel
5727        });
5728
5729        cx.run_until_parked();
5730
5731        panel.update_in(cx, |panel, window, cx| {
5732            panel.set_start_thread_in(
5733                &StartThreadIn::NewWorktree {
5734                    worktree_name: Some("feature worktree".to_string()),
5735                    branch_target: NewWorktreeBranchTarget::CurrentBranch,
5736                },
5737                window,
5738                cx,
5739            );
5740        });
5741
5742        fs.add_linked_worktree_for_repo(
5743            Path::new("/project/.git"),
5744            true,
5745            GitWorktree {
5746                path: PathBuf::from("/linked-feature-worktree"),
5747                ref_name: Some("refs/heads/feature-worktree".into()),
5748                sha: "abcdef1".into(),
5749                is_main: false,
5750            },
5751        )
5752        .await;
5753
5754        project
5755            .update(cx, |project, cx| project.git_scans_complete(cx))
5756            .await;
5757        cx.run_until_parked();
5758
5759        panel.read_with(cx, |panel, _cx| {
5760            assert_eq!(
5761                *panel.start_thread_in(),
5762                StartThreadIn::NewWorktree {
5763                    worktree_name: Some("feature worktree".to_string()),
5764                    branch_target: NewWorktreeBranchTarget::CurrentBranch,
5765                },
5766                "thread target should remain a named new worktree when the external linked worktree does not match the selected branch target",
5767            );
5768        });
5769    }
5770
5771    #[gpui::test]
5772    async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) {
5773        init_test(cx);
5774        cx.update(|cx| {
5775            agent::ThreadStore::init_global(cx);
5776            language_model::LanguageModelRegistry::test(cx);
5777        });
5778
5779        let fs = FakeFs::new(cx.executor());
5780        fs.insert_tree(
5781            "/project",
5782            json!({
5783                ".git": {},
5784                "src": {
5785                    "main.rs": "fn main() {}"
5786                }
5787            }),
5788        )
5789        .await;
5790        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
5791
5792        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5793
5794        let multi_workspace =
5795            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5796
5797        let workspace = multi_workspace
5798            .read_with(cx, |multi_workspace, _cx| {
5799                multi_workspace.workspace().clone()
5800            })
5801            .unwrap();
5802
5803        workspace.update(cx, |workspace, _cx| {
5804            workspace.set_random_database_id();
5805        });
5806
5807        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5808
5809        // Wait for the project to discover the git repository.
5810        cx.run_until_parked();
5811
5812        let panel = workspace.update_in(cx, |workspace, window, cx| {
5813            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5814            workspace.add_panel(panel.clone(), window, cx);
5815            panel
5816        });
5817
5818        cx.run_until_parked();
5819
5820        // Default should be LocalProject.
5821        panel.read_with(cx, |panel, _cx| {
5822            assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject);
5823        });
5824
5825        // Change thread target to NewWorktree.
5826        panel.update_in(cx, |panel, window, cx| {
5827            panel.set_start_thread_in(
5828                &StartThreadIn::NewWorktree {
5829                    worktree_name: None,
5830                    branch_target: NewWorktreeBranchTarget::default(),
5831                },
5832                window,
5833                cx,
5834            );
5835        });
5836
5837        panel.read_with(cx, |panel, _cx| {
5838            assert_eq!(
5839                *panel.start_thread_in(),
5840                StartThreadIn::NewWorktree {
5841                    worktree_name: None,
5842                    branch_target: NewWorktreeBranchTarget::default(),
5843                },
5844                "thread target should be NewWorktree after set_thread_target"
5845            );
5846        });
5847
5848        // Let serialization complete.
5849        cx.run_until_parked();
5850
5851        // Load a fresh panel from the serialized data.
5852        let async_cx = cx.update(|window, cx| window.to_async(cx));
5853        let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
5854            .await
5855            .expect("panel load should succeed");
5856        cx.run_until_parked();
5857
5858        loaded_panel.read_with(cx, |panel, _cx| {
5859            assert_eq!(
5860                *panel.start_thread_in(),
5861                StartThreadIn::NewWorktree {
5862                    worktree_name: None,
5863                    branch_target: NewWorktreeBranchTarget::default(),
5864                },
5865                "thread target should survive serialization round-trip"
5866            );
5867        });
5868    }
5869
5870    #[gpui::test]
5871    async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
5872        init_test(cx);
5873
5874        let fs = FakeFs::new(cx.executor());
5875        cx.update(|cx| {
5876            agent::ThreadStore::init_global(cx);
5877            language_model::LanguageModelRegistry::test(cx);
5878            <dyn fs::Fs>::set_global(fs.clone(), cx);
5879        });
5880
5881        fs.insert_tree(
5882            "/project",
5883            json!({
5884                ".git": {},
5885                "src": {
5886                    "main.rs": "fn main() {}"
5887                }
5888            }),
5889        )
5890        .await;
5891
5892        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
5893
5894        let multi_workspace =
5895            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5896
5897        let workspace = multi_workspace
5898            .read_with(cx, |multi_workspace, _cx| {
5899                multi_workspace.workspace().clone()
5900            })
5901            .unwrap();
5902
5903        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
5904
5905        let panel = workspace.update_in(cx, |workspace, window, cx| {
5906            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
5907            workspace.add_panel(panel.clone(), window, cx);
5908            panel
5909        });
5910
5911        cx.run_until_parked();
5912
5913        // Simulate worktree creation in progress and reset to Uninitialized
5914        panel.update_in(cx, |panel, window, cx| {
5915            panel.worktree_creation_status =
5916                Some((EntityId::from(0u64), WorktreeCreationStatus::Creating));
5917            panel.active_view = ActiveView::Uninitialized;
5918            Panel::set_active(panel, true, window, cx);
5919            assert!(
5920                matches!(panel.active_view, ActiveView::Uninitialized),
5921                "set_active should not create a thread while worktree is being created"
5922            );
5923        });
5924
5925        // Clear the creation status and use open_external_thread_with_server
5926        // (which bypasses new_agent_thread) to verify the panel can transition
5927        // out of Uninitialized. We can't call set_active directly because
5928        // new_agent_thread requires full agent server infrastructure.
5929        panel.update_in(cx, |panel, window, cx| {
5930            panel.worktree_creation_status = None;
5931            panel.active_view = ActiveView::Uninitialized;
5932            panel.open_external_thread_with_server(
5933                Rc::new(StubAgentServer::default_response()),
5934                window,
5935                cx,
5936            );
5937        });
5938
5939        cx.run_until_parked();
5940
5941        panel.read_with(cx, |panel, _cx| {
5942            assert!(
5943                !matches!(panel.active_view, ActiveView::Uninitialized),
5944                "panel should transition out of Uninitialized once worktree creation is cleared"
5945            );
5946        });
5947    }
5948
5949    #[test]
5950    fn test_deserialize_agent_variants() {
5951        // PascalCase (legacy AgentType format, persisted in panel state)
5952        assert_eq!(
5953            serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
5954            Agent::NativeAgent,
5955        );
5956        assert_eq!(
5957            serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
5958            Agent::Custom {
5959                id: "my-agent".into(),
5960            },
5961        );
5962
5963        // Legacy TextThread variant deserializes to NativeAgent
5964        assert_eq!(
5965            serde_json::from_str::<Agent>(r#""TextThread""#).unwrap(),
5966            Agent::NativeAgent,
5967        );
5968
5969        // snake_case (canonical format)
5970        assert_eq!(
5971            serde_json::from_str::<Agent>(r#""native_agent""#).unwrap(),
5972            Agent::NativeAgent,
5973        );
5974        assert_eq!(
5975            serde_json::from_str::<Agent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
5976            Agent::Custom {
5977                id: "my-agent".into(),
5978            },
5979        );
5980
5981        // Serialization uses snake_case
5982        assert_eq!(
5983            serde_json::to_string(&Agent::NativeAgent).unwrap(),
5984            r#""native_agent""#,
5985        );
5986        assert_eq!(
5987            serde_json::to_string(&Agent::Custom {
5988                id: "my-agent".into()
5989            })
5990            .unwrap(),
5991            r#"{"custom":{"name":"my-agent"}}"#,
5992        );
5993    }
5994
5995    #[test]
5996    fn test_resolve_worktree_branch_target() {
5997        let existing_branches = HashSet::from_iter([
5998            "main".to_string(),
5999            "feature".to_string(),
6000            "origin/main".to_string(),
6001        ]);
6002
6003        let resolved = AgentPanel::resolve_worktree_branch_target(
6004            &NewWorktreeBranchTarget::CreateBranch {
6005                name: "new-branch".to_string(),
6006                from_ref: Some("main".to_string()),
6007            },
6008            &existing_branches,
6009            &HashSet::from_iter(["main".to_string()]),
6010        )
6011        .unwrap();
6012        assert_eq!(
6013            resolved,
6014            ("new-branch".to_string(), false, Some("main".to_string()))
6015        );
6016
6017        let resolved = AgentPanel::resolve_worktree_branch_target(
6018            &NewWorktreeBranchTarget::ExistingBranch {
6019                name: "feature".to_string(),
6020            },
6021            &existing_branches,
6022            &HashSet::default(),
6023        )
6024        .unwrap();
6025        assert_eq!(resolved, ("feature".to_string(), true, None));
6026
6027        let resolved = AgentPanel::resolve_worktree_branch_target(
6028            &NewWorktreeBranchTarget::ExistingBranch {
6029                name: "main".to_string(),
6030            },
6031            &existing_branches,
6032            &HashSet::from_iter(["main".to_string()]),
6033        )
6034        .unwrap();
6035        assert_eq!(resolved.1, false);
6036        assert_eq!(resolved.2, Some("main".to_string()));
6037        assert_ne!(resolved.0, "main");
6038        assert!(existing_branches.contains("main"));
6039        assert!(!existing_branches.contains(&resolved.0));
6040    }
6041
6042    #[gpui::test]
6043    async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
6044        init_test(cx);
6045
6046        let app_state = cx.update(|cx| {
6047            agent::ThreadStore::init_global(cx);
6048            language_model::LanguageModelRegistry::test(cx);
6049
6050            let app_state = workspace::AppState::test(cx);
6051            workspace::init(app_state.clone(), cx);
6052            app_state
6053        });
6054
6055        let fs = app_state.fs.as_fake();
6056        fs.insert_tree(
6057            "/project",
6058            json!({
6059                ".git": {},
6060                "src": {
6061                    "main.rs": "fn main() {}"
6062                }
6063            }),
6064        )
6065        .await;
6066        fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6067
6068        let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await;
6069
6070        let multi_workspace =
6071            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6072        multi_workspace
6073            .update(cx, |multi_workspace, _, cx| {
6074                multi_workspace.open_sidebar(cx);
6075            })
6076            .unwrap();
6077
6078        let workspace = multi_workspace
6079            .read_with(cx, |multi_workspace, _cx| {
6080                multi_workspace.workspace().clone()
6081            })
6082            .unwrap();
6083
6084        workspace.update(cx, |workspace, _cx| {
6085            workspace.set_random_database_id();
6086        });
6087
6088        // Register a callback so new workspaces also get an AgentPanel.
6089        cx.update(|cx| {
6090            cx.observe_new(
6091                |workspace: &mut Workspace,
6092                 window: Option<&mut Window>,
6093                 cx: &mut Context<Workspace>| {
6094                    if let Some(window) = window {
6095                        let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6096                        workspace.add_panel(panel, window, cx);
6097                    }
6098                },
6099            )
6100            .detach();
6101        });
6102
6103        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6104
6105        // Wait for the project to discover the git repository.
6106        cx.run_until_parked();
6107
6108        let panel = workspace.update_in(cx, |workspace, window, cx| {
6109            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6110            workspace.add_panel(panel.clone(), window, cx);
6111            panel
6112        });
6113
6114        cx.run_until_parked();
6115
6116        // Open a thread (needed so there's an active thread view).
6117        panel.update_in(cx, |panel, window, cx| {
6118            panel.open_external_thread_with_server(
6119                Rc::new(StubAgentServer::default_response()),
6120                window,
6121                cx,
6122            );
6123        });
6124
6125        cx.run_until_parked();
6126
6127        // Set the selected agent to Codex (a custom agent) and start_thread_in
6128        // to NewWorktree. We do this AFTER opening the thread because
6129        // open_external_thread_with_server overrides selected_agent.
6130        panel.update_in(cx, |panel, window, cx| {
6131            panel.selected_agent = Agent::Custom {
6132                id: CODEX_ID.into(),
6133            };
6134            panel.set_start_thread_in(
6135                &StartThreadIn::NewWorktree {
6136                    worktree_name: None,
6137                    branch_target: NewWorktreeBranchTarget::default(),
6138                },
6139                window,
6140                cx,
6141            );
6142        });
6143
6144        // Verify the panel has the Codex agent selected.
6145        panel.read_with(cx, |panel, _cx| {
6146            assert_eq!(
6147                panel.selected_agent,
6148                Agent::Custom {
6149                    id: CODEX_ID.into()
6150                },
6151            );
6152        });
6153
6154        // Directly call handle_worktree_creation_requested, which is what
6155        // handle_first_send_requested does when start_thread_in == NewWorktree.
6156        let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
6157            "Hello from test",
6158        ))];
6159        panel.update_in(cx, |panel, window, cx| {
6160            panel.handle_worktree_requested(
6161                content,
6162                WorktreeCreationArgs::New {
6163                    worktree_name: None,
6164                    branch_target: NewWorktreeBranchTarget::default(),
6165                },
6166                window,
6167                cx,
6168            );
6169        });
6170
6171        // Let the async worktree creation + workspace setup complete.
6172        cx.run_until_parked();
6173
6174        panel.read_with(cx, |panel, _cx| {
6175            assert_eq!(
6176                panel.start_thread_in(),
6177                &StartThreadIn::LocalProject,
6178                "the original panel should reset start_thread_in back to the local project after creating a worktree workspace",
6179            );
6180        });
6181
6182        // Find the new workspace's AgentPanel and verify it used the Codex agent.
6183        let found_codex = multi_workspace
6184            .read_with(cx, |multi_workspace, cx| {
6185                // There should be more than one workspace now (the original + the new worktree).
6186                assert!(
6187                    multi_workspace.workspaces().count() > 1,
6188                    "expected a new workspace to have been created, found {}",
6189                    multi_workspace.workspaces().count(),
6190                );
6191
6192                // Check the newest workspace's panel for the correct agent.
6193                let new_workspace = multi_workspace
6194                    .workspaces()
6195                    .find(|ws| ws.entity_id() != workspace.entity_id())
6196                    .expect("should find the new workspace");
6197                let new_panel = new_workspace
6198                    .read(cx)
6199                    .panel::<AgentPanel>(cx)
6200                    .expect("new workspace should have an AgentPanel");
6201
6202                new_panel.read(cx).selected_agent.clone()
6203            })
6204            .unwrap();
6205
6206        assert_eq!(
6207            found_codex,
6208            Agent::Custom {
6209                id: CODEX_ID.into()
6210            },
6211            "the new worktree workspace should use the same agent (Codex) that was selected in the original panel",
6212        );
6213    }
6214
6215    #[gpui::test]
6216    async fn test_work_dirs_update_when_worktrees_change(cx: &mut TestAppContext) {
6217        use crate::thread_metadata_store::ThreadMetadataStore;
6218
6219        init_test(cx);
6220        cx.update(|cx| {
6221            agent::ThreadStore::init_global(cx);
6222            language_model::LanguageModelRegistry::test(cx);
6223        });
6224
6225        // Set up a project with one worktree.
6226        let fs = FakeFs::new(cx.executor());
6227        fs.insert_tree("/project_a", json!({ "file.txt": "" }))
6228            .await;
6229        let project = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
6230
6231        let multi_workspace =
6232            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6233        let workspace = multi_workspace
6234            .read_with(cx, |mw, _cx| mw.workspace().clone())
6235            .unwrap();
6236        let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
6237
6238        let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
6239            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6240        });
6241
6242        // Open thread A and send a message. With empty next_prompt_updates it
6243        // stays generating, so opening B will move A to background_threads.
6244        let connection_a = StubAgentConnection::new().with_agent_id("agent-a".into());
6245        open_thread_with_custom_connection(&panel, connection_a.clone(), &mut cx);
6246        send_message(&panel, &mut cx);
6247        let session_id_a = active_session_id(&panel, &cx);
6248
6249        // Open thread C — thread A (generating) moves to background.
6250        // Thread C completes immediately (idle), then opening B moves C to background too.
6251        let connection_c = StubAgentConnection::new().with_agent_id("agent-c".into());
6252        connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
6253            acp::ContentChunk::new("done".into()),
6254        )]);
6255        open_thread_with_custom_connection(&panel, connection_c.clone(), &mut cx);
6256        send_message(&panel, &mut cx);
6257        let session_id_c = active_session_id(&panel, &cx);
6258
6259        // Open thread B — thread C (idle, non-loadable) is retained in background.
6260        let connection_b = StubAgentConnection::new().with_agent_id("agent-b".into());
6261        open_thread_with_custom_connection(&panel, connection_b.clone(), &mut cx);
6262        send_message(&panel, &mut cx);
6263        let session_id_b = active_session_id(&panel, &cx);
6264
6265        let metadata_store = cx.update(|_, cx| ThreadMetadataStore::global(cx));
6266
6267        panel.read_with(&cx, |panel, _cx| {
6268            assert!(
6269                panel.background_threads.contains_key(&session_id_a),
6270                "Thread A should be in background_threads"
6271            );
6272            assert!(
6273                panel.background_threads.contains_key(&session_id_c),
6274                "Thread C should be in background_threads"
6275            );
6276        });
6277
6278        // Verify initial work_dirs for thread B contain only /project_a.
6279        let initial_b_paths = panel.read_with(&cx, |panel, cx| {
6280            let thread = panel.active_agent_thread(cx).unwrap();
6281            thread.read(cx).work_dirs().cloned().unwrap()
6282        });
6283        assert_eq!(
6284            initial_b_paths.ordered_paths().collect::<Vec<_>>(),
6285            vec![&PathBuf::from("/project_a")],
6286            "Thread B should initially have only /project_a"
6287        );
6288
6289        // Now add a second worktree to the project.
6290        fs.insert_tree("/project_b", json!({ "other.txt": "" }))
6291            .await;
6292        let (new_tree, _) = project
6293            .update(&mut cx, |project, cx| {
6294                project.find_or_create_worktree("/project_b", true, cx)
6295            })
6296            .await
6297            .unwrap();
6298        cx.read(|cx| new_tree.read(cx).as_local().unwrap().scan_complete())
6299            .await;
6300        cx.run_until_parked();
6301
6302        // Verify thread B's (active) work_dirs now include both worktrees.
6303        let updated_b_paths = panel.read_with(&cx, |panel, cx| {
6304            let thread = panel.active_agent_thread(cx).unwrap();
6305            thread.read(cx).work_dirs().cloned().unwrap()
6306        });
6307        let mut b_paths_sorted = updated_b_paths.ordered_paths().cloned().collect::<Vec<_>>();
6308        b_paths_sorted.sort();
6309        assert_eq!(
6310            b_paths_sorted,
6311            vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6312            "Thread B work_dirs should include both worktrees after adding /project_b"
6313        );
6314
6315        // Verify thread A's (background) work_dirs are also updated.
6316        let updated_a_paths = panel.read_with(&cx, |panel, cx| {
6317            let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6318            let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6319            root_thread
6320                .read(cx)
6321                .thread
6322                .read(cx)
6323                .work_dirs()
6324                .cloned()
6325                .unwrap()
6326        });
6327        let mut a_paths_sorted = updated_a_paths.ordered_paths().cloned().collect::<Vec<_>>();
6328        a_paths_sorted.sort();
6329        assert_eq!(
6330            a_paths_sorted,
6331            vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6332            "Thread A work_dirs should include both worktrees after adding /project_b"
6333        );
6334
6335        // Verify thread idle C was also updated.
6336        let updated_c_paths = panel.read_with(&cx, |panel, cx| {
6337            let bg_view = panel.background_threads.get(&session_id_c).unwrap();
6338            let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6339            root_thread
6340                .read(cx)
6341                .thread
6342                .read(cx)
6343                .work_dirs()
6344                .cloned()
6345                .unwrap()
6346        });
6347        let mut c_paths_sorted = updated_c_paths.ordered_paths().cloned().collect::<Vec<_>>();
6348        c_paths_sorted.sort();
6349        assert_eq!(
6350            c_paths_sorted,
6351            vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6352            "Thread C (idle background) work_dirs should include both worktrees after adding /project_b"
6353        );
6354
6355        // Verify the metadata store reflects the new paths for running threads only.
6356        cx.run_until_parked();
6357        for (label, session_id) in [("thread B", &session_id_b), ("thread A", &session_id_a)] {
6358            let metadata_paths = metadata_store.read_with(&cx, |store, _cx| {
6359                let metadata = store
6360                    .entry(session_id)
6361                    .unwrap_or_else(|| panic!("{label} thread metadata should exist"));
6362                metadata.folder_paths().clone()
6363            });
6364            let mut sorted = metadata_paths.ordered_paths().cloned().collect::<Vec<_>>();
6365            sorted.sort();
6366            assert_eq!(
6367                sorted,
6368                vec![PathBuf::from("/project_a"), PathBuf::from("/project_b")],
6369                "{label} thread metadata folder_paths should include both worktrees"
6370            );
6371        }
6372
6373        // Now remove a worktree and verify work_dirs shrink.
6374        let worktree_b_id = new_tree.read_with(&cx, |tree, _| tree.id());
6375        project.update(&mut cx, |project, cx| {
6376            project.remove_worktree(worktree_b_id, cx);
6377        });
6378        cx.run_until_parked();
6379
6380        let after_remove_b = panel.read_with(&cx, |panel, cx| {
6381            let thread = panel.active_agent_thread(cx).unwrap();
6382            thread.read(cx).work_dirs().cloned().unwrap()
6383        });
6384        assert_eq!(
6385            after_remove_b.ordered_paths().collect::<Vec<_>>(),
6386            vec![&PathBuf::from("/project_a")],
6387            "Thread B work_dirs should revert to only /project_a after removing /project_b"
6388        );
6389
6390        let after_remove_a = panel.read_with(&cx, |panel, cx| {
6391            let bg_view = panel.background_threads.get(&session_id_a).unwrap();
6392            let root_thread = bg_view.read(cx).root_thread(cx).unwrap();
6393            root_thread
6394                .read(cx)
6395                .thread
6396                .read(cx)
6397                .work_dirs()
6398                .cloned()
6399                .unwrap()
6400        });
6401        assert_eq!(
6402            after_remove_a.ordered_paths().collect::<Vec<_>>(),
6403            vec![&PathBuf::from("/project_a")],
6404            "Thread A work_dirs should revert to only /project_a after removing /project_b"
6405        );
6406    }
6407
6408    #[gpui::test]
6409    async fn test_new_workspace_inherits_global_last_used_agent(cx: &mut TestAppContext) {
6410        init_test(cx);
6411        cx.update(|cx| {
6412            agent::ThreadStore::init_global(cx);
6413            language_model::LanguageModelRegistry::test(cx);
6414            // Use an isolated DB so parallel tests can't overwrite our global key.
6415            cx.set_global(db::AppDatabase::test_new());
6416        });
6417
6418        let custom_agent = Agent::Custom {
6419            id: "my-preferred-agent".into(),
6420        };
6421
6422        // Write a known agent to the global KVP to simulate a user who has
6423        // previously used this agent in another workspace.
6424        let kvp = cx.update(|cx| KeyValueStore::global(cx));
6425        write_global_last_used_agent(kvp, custom_agent.clone()).await;
6426
6427        let fs = FakeFs::new(cx.executor());
6428        let project = Project::test(fs.clone(), [], cx).await;
6429
6430        let multi_workspace =
6431            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6432
6433        let workspace = multi_workspace
6434            .read_with(cx, |multi_workspace, _cx| {
6435                multi_workspace.workspace().clone()
6436            })
6437            .unwrap();
6438
6439        workspace.update(cx, |workspace, _cx| {
6440            workspace.set_random_database_id();
6441        });
6442
6443        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6444
6445        // Load the panel via `load()`, which reads the global fallback
6446        // asynchronously when no per-workspace state exists.
6447        let async_cx = cx.update(|window, cx| window.to_async(cx));
6448        let panel = AgentPanel::load(workspace.downgrade(), async_cx)
6449            .await
6450            .expect("panel load should succeed");
6451        cx.run_until_parked();
6452
6453        panel.read_with(cx, |panel, _cx| {
6454            assert_eq!(
6455                panel.selected_agent, custom_agent,
6456                "new workspace should inherit the global last-used agent"
6457            );
6458        });
6459    }
6460
6461    #[gpui::test]
6462    async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) {
6463        init_test(cx);
6464        cx.update(|cx| {
6465            agent::ThreadStore::init_global(cx);
6466            language_model::LanguageModelRegistry::test(cx);
6467        });
6468
6469        let fs = FakeFs::new(cx.executor());
6470        let project_a = Project::test(fs.clone(), [], cx).await;
6471        let project_b = Project::test(fs, [], cx).await;
6472
6473        let multi_workspace =
6474            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
6475
6476        let workspace_a = multi_workspace
6477            .read_with(cx, |multi_workspace, _cx| {
6478                multi_workspace.workspace().clone()
6479            })
6480            .unwrap();
6481
6482        let workspace_b = multi_workspace
6483            .update(cx, |multi_workspace, window, cx| {
6484                multi_workspace.test_add_workspace(project_b.clone(), window, cx)
6485            })
6486            .unwrap();
6487
6488        workspace_a.update(cx, |workspace, _cx| {
6489            workspace.set_random_database_id();
6490        });
6491        workspace_b.update(cx, |workspace, _cx| {
6492            workspace.set_random_database_id();
6493        });
6494
6495        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6496
6497        let agent_a = Agent::Custom {
6498            id: "agent-alpha".into(),
6499        };
6500        let agent_b = Agent::Custom {
6501            id: "agent-beta".into(),
6502        };
6503
6504        // Set up workspace A with agent_a
6505        let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
6506            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6507        });
6508        panel_a.update(cx, |panel, _cx| {
6509            panel.selected_agent = agent_a.clone();
6510        });
6511
6512        // Set up workspace B with agent_b
6513        let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
6514            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
6515        });
6516        panel_b.update(cx, |panel, _cx| {
6517            panel.selected_agent = agent_b.clone();
6518        });
6519
6520        // Serialize both panels
6521        panel_a.update(cx, |panel, cx| panel.serialize(cx));
6522        panel_b.update(cx, |panel, cx| panel.serialize(cx));
6523        cx.run_until_parked();
6524
6525        // Load fresh panels from serialized state and verify independence
6526        let async_cx = cx.update(|window, cx| window.to_async(cx));
6527        let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
6528            .await
6529            .expect("panel A load should succeed");
6530        cx.run_until_parked();
6531
6532        let async_cx = cx.update(|window, cx| window.to_async(cx));
6533        let loaded_b = AgentPanel::load(workspace_b.downgrade(), async_cx)
6534            .await
6535            .expect("panel B load should succeed");
6536        cx.run_until_parked();
6537
6538        loaded_a.read_with(cx, |panel, _cx| {
6539            assert_eq!(
6540                panel.selected_agent, agent_a,
6541                "workspace A should restore agent-alpha, not agent-beta"
6542            );
6543        });
6544
6545        loaded_b.read_with(cx, |panel, _cx| {
6546            assert_eq!(
6547                panel.selected_agent, agent_b,
6548                "workspace B should restore agent-beta, not agent-alpha"
6549            );
6550        });
6551    }
6552
6553    #[gpui::test]
6554    async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) {
6555        init_test(cx);
6556        cx.update(|cx| {
6557            agent::ThreadStore::init_global(cx);
6558            language_model::LanguageModelRegistry::test(cx);
6559        });
6560
6561        let fs = FakeFs::new(cx.executor());
6562        let project = Project::test(fs.clone(), [], cx).await;
6563
6564        let multi_workspace =
6565            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6566
6567        let workspace = multi_workspace
6568            .read_with(cx, |multi_workspace, _cx| {
6569                multi_workspace.workspace().clone()
6570            })
6571            .unwrap();
6572
6573        workspace.update(cx, |workspace, _cx| {
6574            workspace.set_random_database_id();
6575        });
6576
6577        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
6578
6579        let custom_agent = Agent::Custom {
6580            id: "my-custom-agent".into(),
6581        };
6582
6583        let panel = workspace.update_in(cx, |workspace, window, cx| {
6584            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
6585            workspace.add_panel(panel.clone(), window, cx);
6586            panel
6587        });
6588
6589        // Set selected_agent to a custom agent
6590        panel.update(cx, |panel, _cx| {
6591            panel.selected_agent = custom_agent.clone();
6592        });
6593
6594        // Call new_thread, which internally calls external_thread(None, ...)
6595        // This resolves the agent from self.selected_agent
6596        panel.update_in(cx, |panel, window, cx| {
6597            panel.new_thread(&NewThread, window, cx);
6598        });
6599
6600        panel.read_with(cx, |panel, _cx| {
6601            assert_eq!(
6602                panel.selected_agent, custom_agent,
6603                "selected_agent should remain the custom agent after new_thread"
6604            );
6605            assert!(
6606                panel.active_conversation_view().is_some(),
6607                "a thread should have been created"
6608            );
6609        });
6610    }
6611
6612    #[gpui::test]
6613    async fn test_rollback_all_succeed_returns_ok(cx: &mut TestAppContext) {
6614        init_test(cx);
6615        let fs = FakeFs::new(cx.executor());
6616        cx.update(|cx| {
6617            cx.update_flags(true, vec!["agent-v2".to_string()]);
6618            agent::ThreadStore::init_global(cx);
6619            language_model::LanguageModelRegistry::test(cx);
6620            <dyn fs::Fs>::set_global(fs.clone(), cx);
6621        });
6622
6623        fs.insert_tree(
6624            "/project",
6625            json!({
6626                ".git": {},
6627                "src": { "main.rs": "fn main() {}" }
6628            }),
6629        )
6630        .await;
6631
6632        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6633        cx.executor().run_until_parked();
6634
6635        let repository = project.read_with(cx, |project, cx| {
6636            project.repositories(cx).values().next().unwrap().clone()
6637        });
6638
6639        let multi_workspace =
6640            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6641
6642        let path_a = PathBuf::from("/worktrees/branch/project_a");
6643        let path_b = PathBuf::from("/worktrees/branch/project_b");
6644
6645        let (sender_a, receiver_a) = futures::channel::oneshot::channel::<Result<()>>();
6646        let (sender_b, receiver_b) = futures::channel::oneshot::channel::<Result<()>>();
6647        sender_a.send(Ok(())).unwrap();
6648        sender_b.send(Ok(())).unwrap();
6649
6650        let creation_infos = vec![
6651            (repository.clone(), path_a.clone(), receiver_a),
6652            (repository.clone(), path_b.clone(), receiver_b),
6653        ];
6654
6655        let fs_clone = fs.clone();
6656        let result = multi_workspace
6657            .update(cx, |_, window, cx| {
6658                window.spawn(cx, async move |cx| {
6659                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6660                })
6661            })
6662            .unwrap()
6663            .await;
6664
6665        let paths = result.expect("all succeed should return Ok");
6666        assert_eq!(paths, vec![path_a, path_b]);
6667    }
6668
6669    #[gpui::test]
6670    async fn test_rollback_on_failure_attempts_all_worktrees(cx: &mut TestAppContext) {
6671        init_test(cx);
6672        let fs = FakeFs::new(cx.executor());
6673        cx.update(|cx| {
6674            cx.update_flags(true, vec!["agent-v2".to_string()]);
6675            agent::ThreadStore::init_global(cx);
6676            language_model::LanguageModelRegistry::test(cx);
6677            <dyn fs::Fs>::set_global(fs.clone(), cx);
6678        });
6679
6680        fs.insert_tree(
6681            "/project",
6682            json!({
6683                ".git": {},
6684                "src": { "main.rs": "fn main() {}" }
6685            }),
6686        )
6687        .await;
6688
6689        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6690        cx.executor().run_until_parked();
6691
6692        let repository = project.read_with(cx, |project, cx| {
6693            project.repositories(cx).values().next().unwrap().clone()
6694        });
6695
6696        // Actually create a worktree so it exists in FakeFs for rollback to find.
6697        let success_path = PathBuf::from("/worktrees/branch/project");
6698        cx.update(|cx| {
6699            repository.update(cx, |repo, _| {
6700                repo.create_worktree(
6701                    git::repository::CreateWorktreeTarget::NewBranch {
6702                        branch_name: "branch".to_string(),
6703                        base_sha: None,
6704                    },
6705                    success_path.clone(),
6706                )
6707            })
6708        })
6709        .await
6710        .unwrap()
6711        .unwrap();
6712        cx.executor().run_until_parked();
6713
6714        // Verify the worktree directory exists before rollback.
6715        assert!(
6716            fs.is_dir(&success_path).await,
6717            "worktree directory should exist before rollback"
6718        );
6719
6720        let multi_workspace =
6721            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6722
6723        // Build creation_infos: one success, one failure.
6724        let failed_path = PathBuf::from("/worktrees/branch/failed_project");
6725
6726        let (sender_ok, receiver_ok) = futures::channel::oneshot::channel::<Result<()>>();
6727        let (sender_err, receiver_err) = futures::channel::oneshot::channel::<Result<()>>();
6728        sender_ok.send(Ok(())).unwrap();
6729        sender_err
6730            .send(Err(anyhow!("branch already exists")))
6731            .unwrap();
6732
6733        let creation_infos = vec![
6734            (repository.clone(), success_path.clone(), receiver_ok),
6735            (repository.clone(), failed_path.clone(), receiver_err),
6736        ];
6737
6738        let fs_clone = fs.clone();
6739        let result = multi_workspace
6740            .update(cx, |_, window, cx| {
6741                window.spawn(cx, async move |cx| {
6742                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6743                })
6744            })
6745            .unwrap()
6746            .await;
6747
6748        assert!(
6749            result.is_err(),
6750            "should return error when any creation fails"
6751        );
6752        let err_msg = result.unwrap_err().to_string();
6753        assert!(
6754            err_msg.contains("branch already exists"),
6755            "error should mention the original failure: {err_msg}"
6756        );
6757
6758        // The successful worktree should have been rolled back by git.
6759        cx.executor().run_until_parked();
6760        assert!(
6761            !fs.is_dir(&success_path).await,
6762            "successful worktree directory should be removed by rollback"
6763        );
6764    }
6765
6766    #[gpui::test]
6767    async fn test_rollback_on_canceled_receiver(cx: &mut TestAppContext) {
6768        init_test(cx);
6769        let fs = FakeFs::new(cx.executor());
6770        cx.update(|cx| {
6771            cx.update_flags(true, vec!["agent-v2".to_string()]);
6772            agent::ThreadStore::init_global(cx);
6773            language_model::LanguageModelRegistry::test(cx);
6774            <dyn fs::Fs>::set_global(fs.clone(), cx);
6775        });
6776
6777        fs.insert_tree(
6778            "/project",
6779            json!({
6780                ".git": {},
6781                "src": { "main.rs": "fn main() {}" }
6782            }),
6783        )
6784        .await;
6785
6786        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6787        cx.executor().run_until_parked();
6788
6789        let repository = project.read_with(cx, |project, cx| {
6790            project.repositories(cx).values().next().unwrap().clone()
6791        });
6792
6793        let multi_workspace =
6794            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6795
6796        let path = PathBuf::from("/worktrees/branch/project");
6797
6798        // Drop the sender to simulate a canceled receiver.
6799        let (_sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
6800        drop(_sender);
6801
6802        let creation_infos = vec![(repository.clone(), path.clone(), receiver)];
6803
6804        let fs_clone = fs.clone();
6805        let result = multi_workspace
6806            .update(cx, |_, window, cx| {
6807                window.spawn(cx, async move |cx| {
6808                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6809                })
6810            })
6811            .unwrap()
6812            .await;
6813
6814        assert!(
6815            result.is_err(),
6816            "should return error when receiver is canceled"
6817        );
6818        let err_msg = result.unwrap_err().to_string();
6819        assert!(
6820            err_msg.contains("canceled"),
6821            "error should mention cancellation: {err_msg}"
6822        );
6823    }
6824
6825    #[gpui::test]
6826    async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) {
6827        init_test(cx);
6828        let fs = FakeFs::new(cx.executor());
6829        cx.update(|cx| {
6830            cx.update_flags(true, vec!["agent-v2".to_string()]);
6831            agent::ThreadStore::init_global(cx);
6832            language_model::LanguageModelRegistry::test(cx);
6833            <dyn fs::Fs>::set_global(fs.clone(), cx);
6834        });
6835
6836        fs.insert_tree(
6837            "/project",
6838            json!({
6839                ".git": {},
6840                "src": { "main.rs": "fn main() {}" }
6841            }),
6842        )
6843        .await;
6844
6845        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
6846        cx.executor().run_until_parked();
6847
6848        let repository = project.read_with(cx, |project, cx| {
6849            project.repositories(cx).values().next().unwrap().clone()
6850        });
6851
6852        let multi_workspace =
6853            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6854
6855        // Simulate the orphan state: create_dir_all was called but git
6856        // worktree add failed, leaving a directory with leftover files.
6857        let orphan_path = PathBuf::from("/worktrees/branch/orphan_project");
6858        fs.insert_tree(
6859            "/worktrees/branch/orphan_project",
6860            json!({ "leftover.txt": "junk" }),
6861        )
6862        .await;
6863
6864        assert!(
6865            fs.is_dir(&orphan_path).await,
6866            "orphan dir should exist before rollback"
6867        );
6868
6869        let (sender, receiver) = futures::channel::oneshot::channel::<Result<()>>();
6870        sender.send(Err(anyhow!("hook failed"))).unwrap();
6871
6872        let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)];
6873
6874        let fs_clone = fs.clone();
6875        let result = multi_workspace
6876            .update(cx, |_, window, cx| {
6877                window.spawn(cx, async move |cx| {
6878                    AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await
6879                })
6880            })
6881            .unwrap()
6882            .await;
6883
6884        cx.executor().run_until_parked();
6885
6886        assert!(result.is_err());
6887        assert!(
6888            !fs.is_dir(&orphan_path).await,
6889            "orphan worktree directory should be removed by filesystem cleanup"
6890        );
6891    }
6892
6893    #[gpui::test]
6894    async fn test_worktree_creation_for_remote_project(
6895        cx: &mut TestAppContext,
6896        server_cx: &mut TestAppContext,
6897    ) {
6898        init_test(cx);
6899
6900        let app_state = cx.update(|cx| {
6901            agent::ThreadStore::init_global(cx);
6902            language_model::LanguageModelRegistry::test(cx);
6903
6904            let app_state = workspace::AppState::test(cx);
6905            workspace::init(app_state.clone(), cx);
6906            app_state
6907        });
6908
6909        server_cx.update(|cx| {
6910            release_channel::init(semver::Version::new(0, 0, 0), cx);
6911        });
6912
6913        // Set up the remote server side with a git repo.
6914        let server_fs = FakeFs::new(server_cx.executor());
6915        server_fs
6916            .insert_tree(
6917                "/project",
6918                json!({
6919                    ".git": {},
6920                    "src": {
6921                        "main.rs": "fn main() {}"
6922                    }
6923                }),
6924            )
6925            .await;
6926        server_fs.set_branch_name(Path::new("/project/.git"), Some("main"));
6927
6928        // Create a mock remote connection.
6929        let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx);
6930
6931        server_cx.update(remote_server::HeadlessProject::init);
6932        let server_executor = server_cx.executor();
6933        let _headless = server_cx.new(|cx| {
6934            remote_server::HeadlessProject::new(
6935                remote_server::HeadlessAppState {
6936                    session: server_session,
6937                    fs: server_fs.clone(),
6938                    http_client: Arc::new(http_client::BlockedHttpClient),
6939                    node_runtime: node_runtime::NodeRuntime::unavailable(),
6940                    languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())),
6941                    extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()),
6942                    startup_time: Instant::now(),
6943                },
6944                false,
6945                cx,
6946            )
6947        });
6948
6949        // Connect the client side and build a remote project.
6950        // Use a separate Client to avoid double-registering proto handlers
6951        // (Workspace::test_new creates its own WorkspaceStore from the
6952        // project's client).
6953        let remote_client = remote::RemoteClient::connect_mock(opts, cx).await;
6954        let project = cx.update(|cx| {
6955            let project_client = client::Client::new(
6956                Arc::new(clock::FakeSystemClock::new()),
6957                http_client::FakeHttpClient::with_404_response(),
6958                cx,
6959            );
6960            let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx));
6961            project::Project::remote(
6962                remote_client,
6963                project_client,
6964                node_runtime::NodeRuntime::unavailable(),
6965                user_store,
6966                app_state.languages.clone(),
6967                app_state.fs.clone(),
6968                false,
6969                cx,
6970            )
6971        });
6972
6973        // Open the remote path as a worktree in the project.
6974        let worktree_path = Path::new("/project");
6975        project
6976            .update(cx, |project, cx| {
6977                project.find_or_create_worktree(worktree_path, true, cx)
6978            })
6979            .await
6980            .expect("should be able to open remote worktree");
6981        cx.run_until_parked();
6982
6983        // Verify the project is indeed remote.
6984        project.read_with(cx, |project, cx| {
6985            assert!(!project.is_local(), "project should be remote, not local");
6986            assert!(
6987                project.remote_connection_options(cx).is_some(),
6988                "project should have remote connection options"
6989            );
6990        });
6991
6992        // Create the workspace and agent panel.
6993        let multi_workspace =
6994            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6995        multi_workspace
6996            .update(cx, |multi_workspace, _, cx| {
6997                multi_workspace.open_sidebar(cx);
6998            })
6999            .unwrap();
7000
7001        let workspace = multi_workspace
7002            .read_with(cx, |mw, _cx| mw.workspace().clone())
7003            .unwrap();
7004
7005        workspace.update(cx, |workspace, _cx| {
7006            workspace.set_random_database_id();
7007        });
7008
7009        // Register a callback so new workspaces also get an AgentPanel.
7010        cx.update(|cx| {
7011            cx.observe_new(
7012                |workspace: &mut Workspace,
7013                 window: Option<&mut Window>,
7014                 cx: &mut Context<Workspace>| {
7015                    if let Some(window) = window {
7016                        let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7017                        workspace.add_panel(panel, window, cx);
7018                    }
7019                },
7020            )
7021            .detach();
7022        });
7023
7024        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7025        cx.run_until_parked();
7026
7027        let panel = workspace.update_in(cx, |workspace, window, cx| {
7028            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
7029            workspace.add_panel(panel.clone(), window, cx);
7030            panel
7031        });
7032
7033        cx.run_until_parked();
7034
7035        // Open a thread.
7036        panel.update_in(cx, |panel, window, cx| {
7037            panel.open_external_thread_with_server(
7038                Rc::new(StubAgentServer::default_response()),
7039                window,
7040                cx,
7041            );
7042        });
7043        cx.run_until_parked();
7044
7045        // Set start_thread_in to LinkedWorktree to bypass git worktree
7046        // creation and directly test workspace opening for a known path.
7047        let linked_path = PathBuf::from("/project");
7048        panel.update_in(cx, |panel, window, cx| {
7049            panel.set_start_thread_in(
7050                &StartThreadIn::LinkedWorktree {
7051                    path: linked_path.clone(),
7052                    display_name: "project".to_string(),
7053                },
7054                window,
7055                cx,
7056            );
7057        });
7058
7059        // Trigger worktree creation.
7060        let content = vec![acp::ContentBlock::Text(acp::TextContent::new(
7061            "Hello from remote test",
7062        ))];
7063        panel.update_in(cx, |panel, window, cx| {
7064            panel.handle_worktree_requested(
7065                content,
7066                WorktreeCreationArgs::Linked {
7067                    worktree_path: linked_path,
7068                },
7069                window,
7070                cx,
7071            );
7072        });
7073
7074        // The refactored code uses `find_or_create_workspace`, which
7075        // finds the existing remote workspace (matching paths + host)
7076        // and reuses it instead of creating a new connection.
7077        cx.run_until_parked();
7078
7079        // The task should have completed: the existing workspace was
7080        // found and reused.
7081        panel.read_with(cx, |panel, _cx| {
7082            assert!(
7083                panel.worktree_creation_status.is_none(),
7084                "worktree creation should have completed, but status is: {:?}",
7085                panel.worktree_creation_status
7086            );
7087        });
7088
7089        // The existing remote workspace was reused — no new workspace
7090        // should have been created.
7091        multi_workspace
7092            .read_with(cx, |multi_workspace, cx| {
7093                let project = workspace.read(cx).project().clone();
7094                assert!(
7095                    !project.read(cx).is_local(),
7096                    "workspace project should still be remote, not local"
7097                );
7098                assert_eq!(
7099                    multi_workspace.workspaces().count(),
7100                    1,
7101                    "existing remote workspace should be reused, not a new one created"
7102                );
7103            })
7104            .unwrap();
7105    }
7106
7107    #[gpui::test]
7108    async fn test_selected_agent_syncs_when_navigating_between_threads(cx: &mut TestAppContext) {
7109        let (panel, mut cx) = setup_panel(cx).await;
7110
7111        let custom_agent = Agent::Custom {
7112            id: "my-custom-agent".into(),
7113        };
7114
7115        // Create a draft thread with the custom agent.
7116        panel.update(&mut cx, |panel, _cx| {
7117            panel.selected_agent = custom_agent.clone();
7118        });
7119        panel.update_in(&mut cx, |panel, window, cx| {
7120            panel.new_thread(&NewThread, window, cx);
7121        });
7122        let draft_id = panel.read_with(&cx, |panel, _cx| {
7123            assert_eq!(panel.selected_agent, custom_agent);
7124            panel
7125                .active_draft_id()
7126                .expect("should have an active draft")
7127        });
7128
7129        // Open a different thread (stub agent) — this navigates away from the draft.
7130        let connection = StubAgentConnection::new();
7131        let stub_agent = Agent::Custom { id: "Test".into() };
7132        open_thread_with_connection(&panel, connection.clone(), &mut cx);
7133        let other_session_id = active_session_id(&panel, &cx);
7134
7135        // Send a message so the thread is retained when we navigate away.
7136        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
7137            acp::ContentChunk::new("response".into()),
7138        )]);
7139        send_message(&panel, &mut cx);
7140        cx.run_until_parked();
7141
7142        panel.read_with(&cx, |panel, _cx| {
7143            assert_ne!(
7144                panel.selected_agent, custom_agent,
7145                "selected_agent should have changed to the stub agent"
7146            );
7147        });
7148
7149        // Navigate back to the draft thread.
7150        panel.update_in(&mut cx, |panel, window, cx| {
7151            panel.activate_draft(draft_id, true, window, cx);
7152        });
7153
7154        panel.read_with(&cx, |panel, _cx| {
7155            assert_eq!(
7156                panel.selected_agent, custom_agent,
7157                "selected_agent should sync back to the draft's agent"
7158            );
7159        });
7160
7161        // Navigate to the other thread via load_agent_thread (simulating history click).
7162        panel.update_in(&mut cx, |panel, window, cx| {
7163            panel.load_agent_thread(
7164                stub_agent.clone(),
7165                other_session_id,
7166                None,
7167                None,
7168                true,
7169                window,
7170                cx,
7171            );
7172        });
7173
7174        panel.read_with(&cx, |panel, _cx| {
7175            assert_eq!(
7176                panel.selected_agent, stub_agent,
7177                "selected_agent should sync to the loaded thread's agent"
7178            );
7179        });
7180    }
7181
7182    #[gpui::test]
7183    async fn test_classify_worktrees_skips_non_git_root_with_nested_repo(cx: &mut TestAppContext) {
7184        init_test(cx);
7185        cx.update(|cx| {
7186            agent::ThreadStore::init_global(cx);
7187            language_model::LanguageModelRegistry::test(cx);
7188        });
7189
7190        let fs = FakeFs::new(cx.executor());
7191        fs.insert_tree(
7192            "/repo_a",
7193            json!({
7194                ".git": {},
7195                "src": { "main.rs": "" }
7196            }),
7197        )
7198        .await;
7199        fs.insert_tree(
7200            "/repo_b",
7201            json!({
7202                ".git": {},
7203                "src": { "lib.rs": "" }
7204            }),
7205        )
7206        .await;
7207        // `plain_dir` is NOT a git repo, but contains a nested git repo.
7208        fs.insert_tree(
7209            "/plain_dir",
7210            json!({
7211                "nested_repo": {
7212                    ".git": {},
7213                    "src": { "lib.rs": "" }
7214                }
7215            }),
7216        )
7217        .await;
7218
7219        let project = Project::test(
7220            fs.clone(),
7221            [
7222                Path::new("/repo_a"),
7223                Path::new("/repo_b"),
7224                Path::new("/plain_dir"),
7225            ],
7226            cx,
7227        )
7228        .await;
7229
7230        // Let the worktree scanner discover all `.git` directories.
7231        cx.executor().run_until_parked();
7232
7233        let multi_workspace =
7234            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7235
7236        let workspace = multi_workspace
7237            .read_with(cx, |mw, _cx| mw.workspace().clone())
7238            .unwrap();
7239
7240        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
7241
7242        let panel = workspace.update_in(cx, |workspace, window, cx| {
7243            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
7244        });
7245
7246        cx.run_until_parked();
7247
7248        panel.read_with(cx, |panel, cx| {
7249            let (git_repos, non_git_paths) = panel.classify_worktrees(cx);
7250
7251            let git_work_dirs: Vec<PathBuf> = git_repos
7252                .iter()
7253                .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf())
7254                .collect();
7255
7256            assert_eq!(
7257                git_repos.len(),
7258                2,
7259                "only repo_a and repo_b should be classified as git repos, \
7260                 but got: {git_work_dirs:?}"
7261            );
7262            assert!(
7263                git_work_dirs.contains(&PathBuf::from("/repo_a")),
7264                "repo_a should be in git_repos: {git_work_dirs:?}"
7265            );
7266            assert!(
7267                git_work_dirs.contains(&PathBuf::from("/repo_b")),
7268                "repo_b should be in git_repos: {git_work_dirs:?}"
7269            );
7270
7271            assert_eq!(
7272                non_git_paths,
7273                vec![PathBuf::from("/plain_dir")],
7274                "plain_dir should be classified as a non-git path \
7275                 (not matched to nested_repo inside it)"
7276            );
7277        });
7278    }
7279}