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