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