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