Kick off agent v2 (#44190)

Mikayla Maki , Nathan Sobo , and Zed created

🔜

TODO:
- [x] Add a utility pane to the left and right edges of the workspace
  - [x] Add a maximize button to the left and right side of the pane
- [x] Add a new agents pane
- [x] Add a feature flag turning these off

POV: You're working agentically

<img width="354" height="606" alt="Screenshot 2025-12-13 at 11 50 14 PM"
src="https://github.com/user-attachments/assets/ce5469f9-adc2-47f5-a978-a48bf992f5f7"
/>



Release Notes:

- N/A

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Zed <zed@zed.dev>

Change summary

Cargo.lock                                    |  33 
Cargo.toml                                    |   2 
assets/settings/default.json                  |   2 
crates/agent_settings/src/agent_settings.rs   |   4 
crates/agent_ui/src/agent_ui.rs               |  19 
crates/agent_ui_v2/Cargo.toml                 |  40 +
crates/agent_ui_v2/LICENSE-GPL                |   1 
crates/agent_ui_v2/src/agent_thread_pane.rs   | 290 ++++++++
crates/agent_ui_v2/src/agent_ui_v2.rs         |   4 
crates/agent_ui_v2/src/agents_panel.rs        | 438 ++++++++++++
crates/agent_ui_v2/src/thread_history.rs      | 735 +++++++++++++++++++++
crates/debugger_ui/src/debugger_panel.rs      |   2 
crates/debugger_ui/src/session/running.rs     |   8 
crates/editor/src/split.rs                    |   4 
crates/feature_flags/src/flags.rs             |   6 
crates/settings/src/settings_content/agent.rs |   6 
crates/terminal_view/src/terminal_panel.rs    |  15 
crates/ui/src/components/tab_bar.rs           |  48 +
crates/workspace/Cargo.toml                   |   1 
crates/workspace/src/dock.rs                  | 102 ++
crates/workspace/src/pane.rs                  | 105 ++
crates/workspace/src/pane_group.rs            |  85 ++
crates/workspace/src/utility_pane.rs          | 282 ++++++++
crates/workspace/src/workspace.rs             | 181 ++++
crates/zed/Cargo.toml                         |   1 
crates/zed/src/main.rs                        |   1 
crates/zed/src/zed.rs                         | 133 ++-
crates/zed_actions/src/lib.rs                 |   2 
28 files changed, 2,452 insertions(+), 98 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -406,6 +406,37 @@ dependencies = [
  "zed_actions",
 ]
 
+[[package]]
+name = "agent_ui_v2"
+version = "0.1.0"
+dependencies = [
+ "agent",
+ "agent_servers",
+ "agent_settings",
+ "agent_ui",
+ "anyhow",
+ "assistant_text_thread",
+ "chrono",
+ "db",
+ "editor",
+ "feature_flags",
+ "fs",
+ "fuzzy",
+ "gpui",
+ "menu",
+ "project",
+ "prompt_store",
+ "serde",
+ "serde_json",
+ "settings",
+ "text",
+ "time",
+ "time_format",
+ "ui",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "ahash"
 version = "0.7.8"
@@ -20059,6 +20090,7 @@ dependencies = [
  "component",
  "dap",
  "db",
+ "feature_flags",
  "fs",
  "futures 0.3.31",
  "gpui",
@@ -20475,6 +20507,7 @@ dependencies = [
  "activity_indicator",
  "agent_settings",
  "agent_ui",
+ "agent_ui_v2",
  "anyhow",
  "ashpd 0.11.0",
  "askpass",

Cargo.toml 🔗

@@ -9,6 +9,7 @@ members = [
     "crates/agent_servers",
     "crates/agent_settings",
     "crates/agent_ui",
+    "crates/agent_ui_v2",
     "crates/ai_onboarding",
     "crates/anthropic",
     "crates/askpass",
@@ -242,6 +243,7 @@ action_log = { path = "crates/action_log" }
 agent = { path = "crates/agent" }
 activity_indicator = { path = "crates/activity_indicator" }
 agent_ui = { path = "crates/agent_ui" }
+agent_ui_v2 = { path = "crates/agent_ui_v2" }
 agent_settings = { path = "crates/agent_settings" }
 agent_servers = { path = "crates/agent_servers" }
 ai_onboarding = { path = "crates/ai_onboarding" }

assets/settings/default.json 🔗

@@ -906,6 +906,8 @@
     "button": true,
     // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'.
     "dock": "right",
+    // Where to dock the agents panel. Can be 'left' or 'right'.
+    "agents_panel_dock": "left",
     // Default width when the agent panel is docked to the left or right.
     "default_width": 640,
     // Default height when the agent panel is docked to the bottom.

crates/agent_settings/src/agent_settings.rs 🔗

@@ -9,7 +9,7 @@ use project::DisableAiSettings;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
-    DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection,
+    DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection,
     NotifyWhenAgentWaiting, RegisterSetting, Settings,
 };
 
@@ -24,6 +24,7 @@ pub struct AgentSettings {
     pub enabled: bool,
     pub button: bool,
     pub dock: DockPosition,
+    pub agents_panel_dock: DockSide,
     pub default_width: Pixels,
     pub default_height: Pixels,
     pub default_model: Option<LanguageModelSelection>,
@@ -152,6 +153,7 @@ impl Settings for AgentSettings {
             enabled: agent.enabled.unwrap(),
             button: agent.button.unwrap(),
             dock: agent.dock.unwrap(),
+            agents_panel_dock: agent.agents_panel_dock.unwrap(),
             default_width: px(agent.default_width.unwrap()),
             default_height: px(agent.default_height.unwrap()),
             default_model: Some(agent.default_model.unwrap()),

crates/agent_ui/src/agent_ui.rs 🔗

@@ -1,4 +1,4 @@
-mod acp;
+pub mod acp;
 mod agent_configuration;
 mod agent_diff;
 mod agent_model_selector;
@@ -26,7 +26,7 @@ use agent_settings::{AgentProfileId, AgentSettings};
 use assistant_slash_command::SlashCommandRegistry;
 use client::Client;
 use command_palette_hooks::CommandPaletteFilter;
-use feature_flags::FeatureFlagAppExt as _;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
 use fs::Fs;
 use gpui::{Action, App, Entity, SharedString, actions};
 use language::{
@@ -244,11 +244,17 @@ pub fn init(
         update_command_palette_filter(app_cx);
     })
     .detach();
+
+    cx.on_flags_ready(|_, cx| {
+        update_command_palette_filter(cx);
+    })
+    .detach();
 }
 
 fn update_command_palette_filter(cx: &mut App) {
     let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
     let agent_enabled = AgentSettings::get_global(cx).enabled;
+    let agent_v2_enabled = cx.has_flag::<AgentV2FeatureFlag>();
     let edit_prediction_provider = AllLanguageSettings::get_global(cx)
         .edit_predictions
         .provider;
@@ -269,6 +275,7 @@ fn update_command_palette_filter(cx: &mut App) {
 
         if disable_ai {
             filter.hide_namespace("agent");
+            filter.hide_namespace("agents");
             filter.hide_namespace("assistant");
             filter.hide_namespace("copilot");
             filter.hide_namespace("supermaven");
@@ -280,8 +287,10 @@ fn update_command_palette_filter(cx: &mut App) {
         } else {
             if agent_enabled {
                 filter.show_namespace("agent");
+                filter.show_namespace("agents");
             } else {
                 filter.hide_namespace("agent");
+                filter.hide_namespace("agents");
             }
 
             filter.show_namespace("assistant");
@@ -317,6 +326,9 @@ fn update_command_palette_filter(cx: &mut App) {
 
             filter.show_namespace("zed_predict_onboarding");
             filter.show_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
+            if !agent_v2_enabled {
+                filter.hide_action_types(&[TypeId::of::<zed_actions::agent::ToggleAgentPane>()]);
+            }
         }
     });
 }
@@ -415,7 +427,7 @@ mod tests {
     use gpui::{BorrowAppContext, TestAppContext, px};
     use project::DisableAiSettings;
     use settings::{
-        DefaultAgentView, DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore,
+        DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore,
     };
 
     #[gpui::test]
@@ -434,6 +446,7 @@ mod tests {
             enabled: true,
             button: true,
             dock: DockPosition::Right,
+            agents_panel_dock: DockSide::Left,
             default_width: px(300.),
             default_height: px(600.),
             default_model: None,

crates/agent_ui_v2/Cargo.toml 🔗

@@ -0,0 +1,40 @@
+[package]
+name = "agent_ui_v2"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/agent_ui_v2.rs"
+doctest = false
+
+[dependencies]
+agent.workspace = true
+agent_servers.workspace = true
+agent_settings.workspace = true
+agent_ui.workspace = true
+anyhow.workspace = true
+assistant_text_thread.workspace = true
+chrono.workspace = true
+db.workspace = true
+editor.workspace = true
+feature_flags.workspace = true
+fs.workspace = true
+fuzzy.workspace = true
+gpui.workspace = true
+menu.workspace = true
+project.workspace = true
+prompt_store.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+text.workspace = true
+time.workspace = true
+time_format.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true

crates/agent_ui_v2/src/agent_thread_pane.rs 🔗

@@ -0,0 +1,290 @@
+use agent::{HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
+use agent_servers::AgentServer;
+use agent_settings::AgentSettings;
+use agent_ui::acp::AcpThreadView;
+use fs::Fs;
+use gpui::{
+    Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*,
+};
+use project::Project;
+use prompt_store::PromptStore;
+use serde::{Deserialize, Serialize};
+use settings::DockSide;
+use settings::Settings as _;
+use std::rc::Rc;
+use std::sync::Arc;
+use ui::{
+    App, Clickable as _, Context, DynamicSpacing, IconButton, IconName, IconSize, IntoElement,
+    Label, LabelCommon as _, LabelSize, Render, Tab, Window, div,
+};
+use workspace::Workspace;
+use workspace::dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition};
+use workspace::utility_pane::UtilityPaneSlot;
+
+pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0);
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub enum SerializedHistoryEntryId {
+    AcpThread(String),
+    TextThread(String),
+}
+
+impl From<HistoryEntryId> for SerializedHistoryEntryId {
+    fn from(id: HistoryEntryId) -> Self {
+        match id {
+            HistoryEntryId::AcpThread(session_id) => {
+                SerializedHistoryEntryId::AcpThread(session_id.0.to_string())
+            }
+            HistoryEntryId::TextThread(path) => {
+                SerializedHistoryEntryId::TextThread(path.to_string_lossy().to_string())
+            }
+        }
+    }
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct SerializedAgentThreadPane {
+    pub expanded: bool,
+    pub width: Option<Pixels>,
+    pub thread_id: Option<SerializedHistoryEntryId>,
+}
+
+pub enum AgentsUtilityPaneEvent {
+    StateChanged,
+}
+
+impl EventEmitter<AgentsUtilityPaneEvent> for AgentThreadPane {}
+impl EventEmitter<MinimizePane> for AgentThreadPane {}
+impl EventEmitter<ClosePane> for AgentThreadPane {}
+
+struct ActiveThreadView {
+    view: Entity<AcpThreadView>,
+    thread_id: HistoryEntryId,
+    _notify: Subscription,
+}
+
+pub struct AgentThreadPane {
+    focus_handle: gpui::FocusHandle,
+    expanded: bool,
+    width: Option<Pixels>,
+    thread_view: Option<ActiveThreadView>,
+    workspace: WeakEntity<Workspace>,
+}
+
+impl AgentThreadPane {
+    pub fn new(workspace: WeakEntity<Workspace>, cx: &mut ui::Context<Self>) -> Self {
+        let focus_handle = cx.focus_handle();
+        Self {
+            focus_handle,
+            expanded: false,
+            width: None,
+            thread_view: None,
+            workspace,
+        }
+    }
+
+    pub fn thread_id(&self) -> Option<HistoryEntryId> {
+        self.thread_view.as_ref().map(|tv| tv.thread_id.clone())
+    }
+
+    pub fn serialize(&self) -> SerializedAgentThreadPane {
+        SerializedAgentThreadPane {
+            expanded: self.expanded,
+            width: self.width,
+            thread_id: self.thread_id().map(SerializedHistoryEntryId::from),
+        }
+    }
+
+    pub fn open_thread(
+        &mut self,
+        entry: HistoryEntry,
+        fs: Arc<dyn Fs>,
+        workspace: WeakEntity<Workspace>,
+        project: Entity<Project>,
+        history_store: Entity<HistoryStore>,
+        prompt_store: Option<Entity<PromptStore>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let thread_id = entry.id();
+
+        let resume_thread = match &entry {
+            HistoryEntry::AcpThread(thread) => Some(thread.clone()),
+            HistoryEntry::TextThread(_) => None,
+        };
+
+        let agent: Rc<dyn AgentServer> = Rc::new(NativeAgentServer::new(fs, history_store.clone()));
+
+        let thread_view = cx.new(|cx| {
+            AcpThreadView::new(
+                agent,
+                resume_thread,
+                None,
+                workspace,
+                project,
+                history_store,
+                prompt_store,
+                true,
+                window,
+                cx,
+            )
+        });
+
+        let notify = cx.observe(&thread_view, |_, _, cx| {
+            cx.notify();
+        });
+
+        self.thread_view = Some(ActiveThreadView {
+            view: thread_view,
+            thread_id,
+            _notify: notify,
+        });
+
+        cx.notify();
+    }
+
+    fn title(&self, cx: &App) -> SharedString {
+        if let Some(active_thread_view) = &self.thread_view {
+            let thread_view = active_thread_view.view.read(cx);
+            if let Some(thread) = thread_view.thread() {
+                let title = thread.read(cx).title();
+                if !title.is_empty() {
+                    return title;
+                }
+            }
+            thread_view.title(cx)
+        } else {
+            "Thread".into()
+        }
+    }
+
+    fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let position = self.position(window, cx);
+        let slot = match position {
+            UtilityPanePosition::Left => UtilityPaneSlot::Left,
+            UtilityPanePosition::Right => UtilityPaneSlot::Right,
+        };
+
+        let workspace = self.workspace.clone();
+        let toggle_icon = self.toggle_icon(cx);
+        let title = self.title(cx);
+
+        let make_toggle_button = |workspace: WeakEntity<Workspace>, cx: &App| {
+            div().px(DynamicSpacing::Base06.rems(cx)).child(
+                IconButton::new("toggle_utility_pane", toggle_icon)
+                    .icon_size(IconSize::Small)
+                    .on_click(move |_, window, cx| {
+                        workspace
+                            .update(cx, |workspace, cx| {
+                                workspace.toggle_utility_pane(slot, window, cx)
+                            })
+                            .ok();
+                    }),
+            )
+        };
+
+        let make_close_button = |id: &'static str, cx: &mut Context<Self>| {
+            let on_click = cx.listener(|this, _: &gpui::ClickEvent, _window, cx| {
+                cx.emit(ClosePane);
+                this.thread_view = None;
+                cx.notify();
+            });
+            div().px(DynamicSpacing::Base06.rems(cx)).child(
+                IconButton::new(id, IconName::Close)
+                    .icon_size(IconSize::Small)
+                    .on_click(on_click),
+            )
+        };
+
+        let make_title_label = |title: SharedString, cx: &App| {
+            div()
+                .px(DynamicSpacing::Base06.rems(cx))
+                .child(Label::new(title).size(LabelSize::Small))
+        };
+
+        div()
+            .id("utility-pane-header")
+            .flex()
+            .flex_none()
+            .items_center()
+            .w_full()
+            .h(Tab::container_height(cx))
+            .when(slot == UtilityPaneSlot::Left, |this| {
+                this.child(make_toggle_button(workspace.clone(), cx))
+                    .child(make_title_label(title.clone(), cx))
+                    .child(div().flex_grow())
+                    .child(make_close_button("close_utility_pane_left", cx))
+            })
+            .when(slot == UtilityPaneSlot::Right, |this| {
+                this.child(make_close_button("close_utility_pane_right", cx))
+                    .child(make_title_label(title.clone(), cx))
+                    .child(div().flex_grow())
+                    .child(make_toggle_button(workspace.clone(), cx))
+            })
+    }
+}
+
+impl Focusable for AgentThreadPane {
+    fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle {
+        if let Some(thread_view) = &self.thread_view {
+            thread_view.view.focus_handle(cx)
+        } else {
+            self.focus_handle.clone()
+        }
+    }
+}
+
+impl UtilityPane for AgentThreadPane {
+    fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition {
+        match AgentSettings::get_global(cx).agents_panel_dock {
+            DockSide::Left => UtilityPanePosition::Left,
+            DockSide::Right => UtilityPanePosition::Right,
+        }
+    }
+
+    fn toggle_icon(&self, _cx: &App) -> IconName {
+        IconName::Thread
+    }
+
+    fn expanded(&self, _cx: &App) -> bool {
+        self.expanded
+    }
+
+    fn set_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
+        self.expanded = expanded;
+        cx.emit(AgentsUtilityPaneEvent::StateChanged);
+        cx.notify();
+    }
+
+    fn width(&self, _cx: &App) -> Pixels {
+        self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH)
+    }
+
+    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
+        self.width = width;
+        cx.emit(AgentsUtilityPaneEvent::StateChanged);
+        cx.notify();
+    }
+}
+
+impl Render for AgentThreadPane {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let content = if let Some(thread_view) = &self.thread_view {
+            div().size_full().child(thread_view.view.clone())
+        } else {
+            div()
+                .size_full()
+                .flex()
+                .items_center()
+                .justify_center()
+                .child(Label::new("Select a thread to view details").size(LabelSize::Default))
+        };
+
+        div()
+            .size_full()
+            .flex()
+            .flex_col()
+            .child(self.render_header(window, cx))
+            .child(content)
+    }
+}

crates/agent_ui_v2/src/agents_panel.rs 🔗

@@ -0,0 +1,438 @@
+use agent::{HistoryEntry, HistoryEntryId, HistoryStore};
+use agent_settings::AgentSettings;
+use anyhow::Result;
+use assistant_text_thread::TextThreadStore;
+use db::kvp::KEY_VALUE_STORE;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
+use fs::Fs;
+use gpui::{
+    Action, AsyncWindowContext, Entity, EventEmitter, Focusable, Pixels, Subscription, Task,
+    WeakEntity, actions, prelude::*,
+};
+use project::Project;
+use prompt_store::{PromptBuilder, PromptStore};
+use serde::{Deserialize, Serialize};
+use settings::{Settings as _, update_settings_file};
+use std::sync::Arc;
+use ui::{App, Context, IconName, IntoElement, ParentElement, Render, Styled, Window};
+use util::ResultExt;
+use workspace::{
+    Panel, Workspace,
+    dock::{ClosePane, DockPosition, PanelEvent, UtilityPane},
+    utility_pane::{UtilityPaneSlot, utility_slot_for_dock_position},
+};
+
+use crate::agent_thread_pane::{
+    AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId,
+};
+use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent};
+
+const AGENTS_PANEL_KEY: &str = "agents_panel";
+
+#[derive(Serialize, Deserialize, Debug)]
+struct SerializedAgentsPanel {
+    width: Option<Pixels>,
+    pane: Option<SerializedAgentThreadPane>,
+}
+
+actions!(
+    agents,
+    [
+        /// Toggle the visibility of the agents panel.
+        ToggleAgentsPanel
+    ]
+);
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _, _| {
+        workspace.register_action(|workspace, _: &ToggleAgentsPanel, window, cx| {
+            workspace.toggle_panel_focus::<AgentsPanel>(window, cx);
+        });
+    })
+    .detach();
+}
+
+pub struct AgentsPanel {
+    focus_handle: gpui::FocusHandle,
+    workspace: WeakEntity<Workspace>,
+    project: Entity<Project>,
+    agent_thread_pane: Option<Entity<AgentThreadPane>>,
+    history: Entity<AcpThreadHistory>,
+    history_store: Entity<HistoryStore>,
+    prompt_store: Option<Entity<PromptStore>>,
+    fs: Arc<dyn Fs>,
+    width: Option<Pixels>,
+    pending_serialization: Task<Option<()>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl AgentsPanel {
+    pub fn load(
+        workspace: WeakEntity<Workspace>,
+        cx: AsyncWindowContext,
+    ) -> Task<Result<Entity<Self>, anyhow::Error>> {
+        cx.spawn(async move |cx| {
+            let serialized_panel = cx
+                .background_spawn(async move {
+                    KEY_VALUE_STORE
+                        .read_kvp(AGENTS_PANEL_KEY)
+                        .ok()
+                        .flatten()
+                        .and_then(|panel| {
+                            serde_json::from_str::<SerializedAgentsPanel>(&panel).ok()
+                        })
+                })
+                .await;
+
+            let (fs, project, prompt_builder) = workspace.update(cx, |workspace, cx| {
+                let fs = workspace.app_state().fs.clone();
+                let project = workspace.project().clone();
+                let prompt_builder = PromptBuilder::load(fs.clone(), false, cx);
+                (fs, project, prompt_builder)
+            })?;
+
+            let text_thread_store = workspace
+                .update(cx, |_, cx| {
+                    TextThreadStore::new(
+                        project.clone(),
+                        prompt_builder.clone(),
+                        Default::default(),
+                        cx,
+                    )
+                })?
+                .await?;
+
+            let prompt_store = workspace
+                .update(cx, |_, cx| PromptStore::global(cx))?
+                .await
+                .log_err();
+
+            workspace.update_in(cx, |_, window, cx| {
+                cx.new(|cx| {
+                    let mut panel = Self::new(
+                        workspace.clone(),
+                        fs,
+                        project,
+                        prompt_store,
+                        text_thread_store,
+                        window,
+                        cx,
+                    );
+                    if let Some(serialized_panel) = serialized_panel {
+                        panel.width = serialized_panel.width;
+                        if let Some(serialized_pane) = serialized_panel.pane {
+                            panel.restore_utility_pane(serialized_pane, window, cx);
+                        }
+                    }
+                    panel
+                })
+            })
+        })
+    }
+
+    fn new(
+        workspace: WeakEntity<Workspace>,
+        fs: Arc<dyn Fs>,
+        project: Entity<Project>,
+        prompt_store: Option<Entity<PromptStore>>,
+        text_thread_store: Entity<TextThreadStore>,
+        window: &mut Window,
+        cx: &mut ui::Context<Self>,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+
+        let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
+        let history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx));
+
+        let this = cx.weak_entity();
+        let subscriptions = vec![
+            cx.subscribe_in(&history, window, Self::handle_history_event),
+            cx.on_flags_ready(move |_, cx| {
+                this.update(cx, |_, cx| {
+                    cx.notify();
+                })
+                .ok();
+            }),
+        ];
+
+        Self {
+            focus_handle,
+            workspace,
+            project,
+            agent_thread_pane: None,
+            history,
+            history_store,
+            prompt_store,
+            fs,
+            width: None,
+            pending_serialization: Task::ready(None),
+            _subscriptions: subscriptions,
+        }
+    }
+
+    fn restore_utility_pane(
+        &mut self,
+        serialized_pane: SerializedAgentThreadPane,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(thread_id) = &serialized_pane.thread_id else {
+            return;
+        };
+
+        let entry = self
+            .history_store
+            .read(cx)
+            .entries()
+            .find(|e| match (&e.id(), thread_id) {
+                (
+                    HistoryEntryId::AcpThread(session_id),
+                    SerializedHistoryEntryId::AcpThread(id),
+                ) => session_id.to_string() == *id,
+                (HistoryEntryId::TextThread(path), SerializedHistoryEntryId::TextThread(id)) => {
+                    path.to_string_lossy() == *id
+                }
+                _ => false,
+            });
+
+        if let Some(entry) = entry {
+            self.open_thread(
+                entry,
+                serialized_pane.expanded,
+                serialized_pane.width,
+                window,
+                cx,
+            );
+        }
+    }
+
+    fn handle_utility_pane_event(
+        &mut self,
+        _utility_pane: Entity<AgentThreadPane>,
+        event: &AgentsUtilityPaneEvent,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            AgentsUtilityPaneEvent::StateChanged => {
+                self.serialize(cx);
+                cx.notify();
+            }
+        }
+    }
+
+    fn handle_close_pane_event(
+        &mut self,
+        _utility_pane: Entity<AgentThreadPane>,
+        _event: &ClosePane,
+        cx: &mut Context<Self>,
+    ) {
+        self.agent_thread_pane = None;
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn handle_history_event(
+        &mut self,
+        _history: &Entity<AcpThreadHistory>,
+        event: &ThreadHistoryEvent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            ThreadHistoryEvent::Open(entry) => {
+                self.open_thread(entry.clone(), true, None, window, cx);
+            }
+        }
+    }
+
+    fn open_thread(
+        &mut self,
+        entry: HistoryEntry,
+        expanded: bool,
+        width: Option<Pixels>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let entry_id = entry.id();
+
+        if let Some(existing_pane) = &self.agent_thread_pane {
+            if existing_pane.read(cx).thread_id() == Some(entry_id) {
+                existing_pane.update(cx, |pane, cx| {
+                    pane.set_expanded(true, cx);
+                });
+                return;
+            }
+        }
+
+        let fs = self.fs.clone();
+        let workspace = self.workspace.clone();
+        let project = self.project.clone();
+        let history_store = self.history_store.clone();
+        let prompt_store = self.prompt_store.clone();
+
+        let agent_thread_pane = cx.new(|cx| {
+            let mut pane = AgentThreadPane::new(workspace.clone(), cx);
+            pane.open_thread(
+                entry,
+                fs,
+                workspace.clone(),
+                project,
+                history_store,
+                prompt_store,
+                window,
+                cx,
+            );
+            if let Some(width) = width {
+                pane.set_width(Some(width), cx);
+            }
+            pane.set_expanded(expanded, cx);
+            pane
+        });
+
+        let state_subscription = cx.subscribe(&agent_thread_pane, Self::handle_utility_pane_event);
+        let close_subscription = cx.subscribe(&agent_thread_pane, Self::handle_close_pane_event);
+
+        self._subscriptions.push(state_subscription);
+        self._subscriptions.push(close_subscription);
+
+        let slot = self.utility_slot(window, cx);
+        let panel_id = cx.entity_id();
+
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                workspace.register_utility_pane(slot, panel_id, agent_thread_pane.clone(), cx);
+            });
+        }
+
+        self.agent_thread_pane = Some(agent_thread_pane);
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn utility_slot(&self, window: &Window, cx: &App) -> UtilityPaneSlot {
+        let position = self.position(window, cx);
+        utility_slot_for_dock_position(position)
+    }
+
+    fn re_register_utility_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(pane) = &self.agent_thread_pane {
+            let slot = self.utility_slot(window, cx);
+            let panel_id = cx.entity_id();
+            let pane = pane.clone();
+
+            if let Some(workspace) = self.workspace.upgrade() {
+                workspace.update(cx, |workspace, cx| {
+                    workspace.register_utility_pane(slot, panel_id, pane, cx);
+                });
+            }
+        }
+    }
+
+    fn serialize(&mut self, cx: &mut Context<Self>) {
+        let width = self.width;
+        let pane = self
+            .agent_thread_pane
+            .as_ref()
+            .map(|pane| pane.read(cx).serialize());
+
+        self.pending_serialization = cx.background_spawn(async move {
+            KEY_VALUE_STORE
+                .write_kvp(
+                    AGENTS_PANEL_KEY.into(),
+                    serde_json::to_string(&SerializedAgentsPanel { width, pane }).unwrap(),
+                )
+                .await
+                .log_err()
+        });
+    }
+}
+
+impl EventEmitter<PanelEvent> for AgentsPanel {}
+
+impl Focusable for AgentsPanel {
+    fn focus_handle(&self, _cx: &ui::App) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Panel for AgentsPanel {
+    fn persistent_name() -> &'static str {
+        "AgentsPanel"
+    }
+
+    fn panel_key() -> &'static str {
+        AGENTS_PANEL_KEY
+    }
+
+    fn position(&self, _window: &Window, cx: &App) -> DockPosition {
+        match AgentSettings::get_global(cx).agents_panel_dock {
+            settings::DockSide::Left => DockPosition::Left,
+            settings::DockSide::Right => DockPosition::Right,
+        }
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        position != DockPosition::Bottom
+    }
+
+    fn set_position(
+        &mut self,
+        position: DockPosition,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        update_settings_file(self.fs.clone(), cx, move |settings, _| {
+            settings.agent.get_or_insert_default().agents_panel_dock = Some(match position {
+                DockPosition::Left => settings::DockSide::Left,
+                DockPosition::Bottom => settings::DockSide::Right,
+                DockPosition::Right => settings::DockSide::Left,
+            });
+        });
+        self.re_register_utility_pane(window, cx);
+    }
+
+    fn size(&self, window: &Window, cx: &App) -> Pixels {
+        let settings = AgentSettings::get_global(cx);
+        match self.position(window, cx) {
+            DockPosition::Left | DockPosition::Right => {
+                self.width.unwrap_or(settings.default_width)
+            }
+            DockPosition::Bottom => self.width.unwrap_or(settings.default_height),
+        }
+    }
+
+    fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
+        match self.position(window, cx) {
+            DockPosition::Left | DockPosition::Right => self.width = size,
+            DockPosition::Bottom => {}
+        }
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
+        (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgent)
+    }
+
+    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
+        Some("Agents Panel")
+    }
+
+    fn toggle_action(&self) -> Box<dyn Action> {
+        Box::new(ToggleAgentsPanel)
+    }
+
+    fn activation_priority(&self) -> u32 {
+        4
+    }
+
+    fn enabled(&self, cx: &App) -> bool {
+        AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::<AgentV2FeatureFlag>()
+    }
+}
+
+impl Render for AgentsPanel {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        gpui::div().size_full().child(self.history.clone())
+    }
+}

crates/agent_ui_v2/src/thread_history.rs 🔗

@@ -0,0 +1,735 @@
+use agent::{HistoryEntry, HistoryStore};
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
+use editor::{Editor, EditorEvent};
+use fuzzy::StringMatchCandidate;
+use gpui::{
+    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
+    UniformListScrollHandle, Window, actions, uniform_list,
+};
+use std::{fmt::Display, ops::Range};
+use text::Bias;
+use time::{OffsetDateTime, UtcOffset};
+use ui::{
+    HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar,
+    prelude::*,
+};
+
+actions!(
+    agents,
+    [
+        /// Removes all thread history.
+        RemoveHistory,
+        /// Removes the currently selected thread.
+        RemoveSelectedThread,
+    ]
+);
+
+pub struct AcpThreadHistory {
+    pub(crate) history_store: Entity<HistoryStore>,
+    scroll_handle: UniformListScrollHandle,
+    selected_index: usize,
+    hovered_index: Option<usize>,
+    search_editor: Entity<Editor>,
+    search_query: SharedString,
+    visible_items: Vec<ListItemType>,
+    local_timezone: UtcOffset,
+    confirming_delete_history: bool,
+    _update_task: Task<()>,
+    _subscriptions: Vec<gpui::Subscription>,
+}
+
+enum ListItemType {
+    BucketSeparator(TimeBucket),
+    Entry {
+        entry: HistoryEntry,
+        format: EntryTimeFormat,
+    },
+    SearchResult {
+        entry: HistoryEntry,
+        positions: Vec<usize>,
+    },
+}
+
+impl ListItemType {
+    fn history_entry(&self) -> Option<&HistoryEntry> {
+        match self {
+            ListItemType::Entry { entry, .. } => Some(entry),
+            ListItemType::SearchResult { entry, .. } => Some(entry),
+            _ => None,
+        }
+    }
+}
+
+#[allow(dead_code)]
+pub enum ThreadHistoryEvent {
+    Open(HistoryEntry),
+}
+
+impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
+
+impl AcpThreadHistory {
+    pub fn new(
+        history_store: Entity<agent::HistoryStore>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let search_editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_placeholder_text("Search threads...", window, cx);
+            editor
+        });
+
+        let search_editor_subscription =
+            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
+                if let EditorEvent::BufferEdited = event {
+                    let query = search_editor.read(cx).text(cx);
+                    if this.search_query != query {
+                        this.search_query = query.into();
+                        this.update_visible_items(false, cx);
+                    }
+                }
+            });
+
+        let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
+            this.update_visible_items(true, cx);
+        });
+
+        let scroll_handle = UniformListScrollHandle::default();
+
+        let mut this = Self {
+            history_store,
+            scroll_handle,
+            selected_index: 0,
+            hovered_index: None,
+            visible_items: Default::default(),
+            search_editor,
+            local_timezone: UtcOffset::from_whole_seconds(
+                chrono::Local::now().offset().local_minus_utc(),
+            )
+            .unwrap(),
+            search_query: SharedString::default(),
+            confirming_delete_history: false,
+            _subscriptions: vec![search_editor_subscription, history_store_subscription],
+            _update_task: Task::ready(()),
+        };
+        this.update_visible_items(false, cx);
+        this
+    }
+
+    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
+        let entries = self
+            .history_store
+            .update(cx, |store, _| store.entries().collect());
+        let new_list_items = if self.search_query.is_empty() {
+            self.add_list_separators(entries, cx)
+        } else {
+            self.filter_search_results(entries, cx)
+        };
+        let selected_history_entry = if preserve_selected_item {
+            self.selected_history_entry().cloned()
+        } else {
+            None
+        };
+
+        self._update_task = cx.spawn(async move |this, cx| {
+            let new_visible_items = new_list_items.await;
+            this.update(cx, |this, cx| {
+                let new_selected_index = if let Some(history_entry) = selected_history_entry {
+                    let history_entry_id = history_entry.id();
+                    new_visible_items
+                        .iter()
+                        .position(|visible_entry| {
+                            visible_entry
+                                .history_entry()
+                                .is_some_and(|entry| entry.id() == history_entry_id)
+                        })
+                        .unwrap_or(0)
+                } else {
+                    0
+                };
+
+                this.visible_items = new_visible_items;
+                this.set_selected_index(new_selected_index, Bias::Right, cx);
+                cx.notify();
+            })
+            .ok();
+        });
+    }
+
+    fn add_list_separators(&self, entries: Vec<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
+        cx.background_spawn(async move {
+            let mut items = Vec::with_capacity(entries.len() + 1);
+            let mut bucket = None;
+            let today = Local::now().naive_local().date();
+
+            for entry in entries.into_iter() {
+                let entry_date = entry
+                    .updated_at()
+                    .with_timezone(&Local)
+                    .naive_local()
+                    .date();
+                let entry_bucket = TimeBucket::from_dates(today, entry_date);
+
+                if Some(entry_bucket) != bucket {
+                    bucket = Some(entry_bucket);
+                    items.push(ListItemType::BucketSeparator(entry_bucket));
+                }
+
+                items.push(ListItemType::Entry {
+                    entry,
+                    format: entry_bucket.into(),
+                });
+            }
+            items
+        })
+    }
+
+    fn filter_search_results(
+        &self,
+        entries: Vec<HistoryEntry>,
+        cx: &App,
+    ) -> Task<Vec<ListItemType>> {
+        let query = self.search_query.clone();
+        cx.background_spawn({
+            let executor = cx.background_executor().clone();
+            async move {
+                let mut candidates = Vec::with_capacity(entries.len());
+
+                for (idx, entry) in entries.iter().enumerate() {
+                    candidates.push(StringMatchCandidate::new(idx, entry.title()));
+                }
+
+                const MAX_MATCHES: usize = 100;
+
+                let matches = fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    true,
+                    MAX_MATCHES,
+                    &Default::default(),
+                    executor,
+                )
+                .await;
+
+                matches
+                    .into_iter()
+                    .map(|search_match| ListItemType::SearchResult {
+                        entry: entries[search_match.candidate_id].clone(),
+                        positions: search_match.positions,
+                    })
+                    .collect()
+            }
+        })
+    }
+
+    fn search_produced_no_matches(&self) -> bool {
+        self.visible_items.is_empty() && !self.search_query.is_empty()
+    }
+
+    fn selected_history_entry(&self) -> Option<&HistoryEntry> {
+        self.get_history_entry(self.selected_index)
+    }
+
+    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> {
+        self.visible_items.get(visible_items_ix)?.history_entry()
+    }
+
+    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
+        if self.visible_items.is_empty() {
+            self.selected_index = 0;
+            return;
+        }
+        while matches!(
+            self.visible_items.get(index),
+            None | Some(ListItemType::BucketSeparator(..))
+        ) {
+            index = match bias {
+                Bias::Left => {
+                    if index == 0 {
+                        self.visible_items.len() - 1
+                    } else {
+                        index - 1
+                    }
+                }
+                Bias::Right => {
+                    if index >= self.visible_items.len() - 1 {
+                        0
+                    } else {
+                        index + 1
+                    }
+                }
+            };
+        }
+        self.selected_index = index;
+        self.scroll_handle
+            .scroll_to_item(index, ScrollStrategy::Top);
+        cx.notify()
+    }
+
+    pub fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.selected_index == 0 {
+            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+        } else {
+            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
+        }
+    }
+
+    pub fn select_next(
+        &mut self,
+        _: &menu::SelectNext,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.selected_index == self.visible_items.len() - 1 {
+            self.set_selected_index(0, Bias::Right, cx);
+        } else {
+            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
+        }
+    }
+
+    fn select_first(
+        &mut self,
+        _: &menu::SelectFirst,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.set_selected_index(0, Bias::Right, cx);
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+        self.confirm_entry(self.selected_index, cx);
+    }
+
+    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+        let Some(entry) = self.get_history_entry(ix) else {
+            return;
+        };
+        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
+    }
+
+    fn remove_selected_thread(
+        &mut self,
+        _: &RemoveSelectedThread,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.remove_thread(self.selected_index, cx)
+    }
+
+    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
+        let Some(entry) = self.get_history_entry(visible_item_ix) else {
+            return;
+        };
+
+        let task = match entry {
+            HistoryEntry::AcpThread(thread) => self
+                .history_store
+                .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)),
+            HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| {
+                this.delete_text_thread(text_thread.path.clone(), cx)
+            }),
+        };
+        task.detach_and_log_err(cx);
+    }
+
+    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.history_store.update(cx, |store, cx| {
+            store.delete_threads(cx).detach_and_log_err(cx)
+        });
+        self.confirming_delete_history = false;
+        cx.notify();
+    }
+
+    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.confirming_delete_history = true;
+        cx.notify();
+    }
+
+    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.confirming_delete_history = false;
+        cx.notify();
+    }
+
+    fn render_list_items(
+        &mut self,
+        range: Range<usize>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<AnyElement> {
+        self.visible_items
+            .get(range.clone())
+            .into_iter()
+            .flatten()
+            .enumerate()
+            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
+            .collect()
+    }
+
+    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
+        match item {
+            ListItemType::Entry { entry, format } => self
+                .render_history_entry(entry, *format, ix, Vec::default(), cx)
+                .into_any(),
+            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
+                entry,
+                EntryTimeFormat::DateAndTime,
+                ix,
+                positions.clone(),
+                cx,
+            ),
+            ListItemType::BucketSeparator(bucket) => div()
+                .px(DynamicSpacing::Base06.rems(cx))
+                .pt_2()
+                .pb_1()
+                .child(
+                    Label::new(bucket.to_string())
+                        .size(LabelSize::XSmall)
+                        .color(Color::Muted),
+                )
+                .into_any_element(),
+        }
+    }
+
+    fn render_history_entry(
+        &self,
+        entry: &HistoryEntry,
+        format: EntryTimeFormat,
+        ix: usize,
+        highlight_positions: Vec<usize>,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let selected = ix == self.selected_index;
+        let hovered = Some(ix) == self.hovered_index;
+        let timestamp = entry.updated_at().timestamp();
+        let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
+
+        h_flex()
+            .w_full()
+            .pb_1()
+            .child(
+                ListItem::new(ix)
+                    .rounded()
+                    .toggle_state(selected)
+                    .spacing(ListItemSpacing::Sparse)
+                    .start_slot(
+                        h_flex()
+                            .w_full()
+                            .gap_2()
+                            .justify_between()
+                            .child(
+                                HighlightedLabel::new(entry.title(), highlight_positions)
+                                    .size(LabelSize::Small)
+                                    .truncate(),
+                            )
+                            .child(
+                                Label::new(thread_timestamp)
+                                    .color(Color::Muted)
+                                    .size(LabelSize::XSmall),
+                            ),
+                    )
+                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+                        if *is_hovered {
+                            this.hovered_index = Some(ix);
+                        } else if this.hovered_index == Some(ix) {
+                            this.hovered_index = None;
+                        }
+
+                        cx.notify();
+                    }))
+                    .end_slot::<IconButton>(if hovered {
+                        Some(
+                            IconButton::new("delete", IconName::Trash)
+                                .shape(IconButtonShape::Square)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Muted)
+                                .tooltip(move |_window, cx| {
+                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
+                                })
+                                .on_click(cx.listener(move |this, _, _, cx| {
+                                    this.remove_thread(ix, cx);
+                                    cx.stop_propagation()
+                                })),
+                        )
+                    } else {
+                        None
+                    })
+                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
+            )
+            .into_any_element()
+    }
+}
+
+impl Focusable for AcpThreadHistory {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.search_editor.focus_handle(cx)
+    }
+}
+
+impl Render for AcpThreadHistory {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_no_history = self.history_store.read(cx).is_empty(cx);
+
+        v_flex()
+            .key_context("ThreadHistory")
+            .size_full()
+            .bg(cx.theme().colors().panel_background)
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::remove_selected_thread))
+            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
+                this.remove_history(window, cx);
+            }))
+            .child(
+                h_flex()
+                    .h(Tab::container_height(cx))
+                    .w_full()
+                    .py_1()
+                    .px_2()
+                    .gap_2()
+                    .justify_between()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
+                    .child(
+                        Icon::new(IconName::MagnifyingGlass)
+                            .color(Color::Muted)
+                            .size(IconSize::Small),
+                    )
+                    .child(self.search_editor.clone()),
+            )
+            .child({
+                let view = v_flex()
+                    .id("list-container")
+                    .relative()
+                    .overflow_hidden()
+                    .flex_grow();
+
+                if has_no_history {
+                    view.justify_center().items_center().child(
+                        Label::new("You don't have any past threads yet.")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                } else if self.search_produced_no_matches() {
+                    view.justify_center()
+                        .items_center()
+                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
+                } else {
+                    view.child(
+                        uniform_list(
+                            "thread-history",
+                            self.visible_items.len(),
+                            cx.processor(|this, range: Range<usize>, window, cx| {
+                                this.render_list_items(range, window, cx)
+                            }),
+                        )
+                        .p_1()
+                        .pr_4()
+                        .track_scroll(&self.scroll_handle)
+                        .flex_grow(),
+                    )
+                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+                }
+            })
+            .when(!has_no_history, |this| {
+                this.child(
+                    h_flex()
+                        .p_2()
+                        .border_t_1()
+                        .border_color(cx.theme().colors().border_variant)
+                        .when(!self.confirming_delete_history, |this| {
+                            this.child(
+                                Button::new("delete_history", "Delete All History")
+                                    .full_width()
+                                    .style(ButtonStyle::Outlined)
+                                    .label_size(LabelSize::Small)
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.prompt_delete_history(window, cx);
+                                    })),
+                            )
+                        })
+                        .when(self.confirming_delete_history, |this| {
+                            this.w_full()
+                                .gap_2()
+                                .flex_wrap()
+                                .justify_between()
+                                .child(
+                                    h_flex()
+                                        .flex_wrap()
+                                        .gap_1()
+                                        .child(
+                                            Label::new("Delete all threads?")
+                                                .size(LabelSize::Small),
+                                        )
+                                        .child(
+                                            Label::new("You won't be able to recover them later.")
+                                                .size(LabelSize::Small)
+                                                .color(Color::Muted),
+                                        ),
+                                )
+                                .child(
+                                    h_flex()
+                                        .gap_1()
+                                        .child(
+                                            Button::new("cancel_delete", "Cancel")
+                                                .label_size(LabelSize::Small)
+                                                .on_click(cx.listener(|this, _, window, cx| {
+                                                    this.cancel_delete_history(window, cx);
+                                                })),
+                                        )
+                                        .child(
+                                            Button::new("confirm_delete", "Delete")
+                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
+                                                .color(Color::Error)
+                                                .label_size(LabelSize::Small)
+                                                .on_click(cx.listener(|_, _, window, cx| {
+                                                    window.dispatch_action(
+                                                        Box::new(RemoveHistory),
+                                                        cx,
+                                                    );
+                                                })),
+                                        ),
+                                )
+                        }),
+                )
+            })
+    }
+}
+
+#[derive(Clone, Copy)]
+pub enum EntryTimeFormat {
+    DateAndTime,
+    TimeOnly,
+}
+
+impl EntryTimeFormat {
+    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
+        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
+
+        match self {
+            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
+                timestamp,
+                OffsetDateTime::now_utc(),
+                timezone,
+                time_format::TimestampFormat::EnhancedAbsolute,
+            ),
+            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
+        }
+    }
+}
+
+impl From<TimeBucket> for EntryTimeFormat {
+    fn from(bucket: TimeBucket) -> Self {
+        match bucket {
+            TimeBucket::Today => EntryTimeFormat::TimeOnly,
+            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
+            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
+            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
+            TimeBucket::All => EntryTimeFormat::DateAndTime,
+        }
+    }
+}
+
+#[derive(PartialEq, Eq, Clone, Copy, Debug)]
+enum TimeBucket {
+    Today,
+    Yesterday,
+    ThisWeek,
+    PastWeek,
+    All,
+}
+
+impl TimeBucket {
+    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
+        if date == reference {
+            return TimeBucket::Today;
+        }
+
+        if date == reference - TimeDelta::days(1) {
+            return TimeBucket::Yesterday;
+        }
+
+        let week = date.iso_week();
+
+        if reference.iso_week() == week {
+            return TimeBucket::ThisWeek;
+        }
+
+        let last_week = (reference - TimeDelta::days(7)).iso_week();
+
+        if week == last_week {
+            return TimeBucket::PastWeek;
+        }
+
+        TimeBucket::All
+    }
+}
+
+impl Display for TimeBucket {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TimeBucket::Today => write!(f, "Today"),
+            TimeBucket::Yesterday => write!(f, "Yesterday"),
+            TimeBucket::ThisWeek => write!(f, "This Week"),
+            TimeBucket::PastWeek => write!(f, "Past Week"),
+            TimeBucket::All => write!(f, "All"),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chrono::NaiveDate;
+
+    #[test]
+    fn test_time_bucket_from_dates() {
+        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
+
+        let date = today;
+        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
+
+        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
+
+        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
+
+        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
+
+        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
+
+        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
+
+        // All: not in this week or last week
+        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
+
+        // Test year boundary cases
+        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
+
+        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
+        assert_eq!(
+            TimeBucket::from_dates(new_year, date),
+            TimeBucket::Yesterday
+        );
+
+        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
+        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
+    }
+}

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -1557,7 +1557,7 @@ impl Panel for DebugPanel {
             self.sessions_with_children.keys().for_each(|session_item| {
                 session_item.update(cx, |item, cx| {
                     item.running_state()
-                        .update(cx, |state, _| state.invert_axies())
+                        .update(cx, |state, cx| state.invert_axies(cx))
                 })
             });
         }

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

@@ -348,7 +348,7 @@ pub(crate) fn new_debugger_pane(
                         debug_assert!(_previous_subscription.is_none());
                         running
                             .panes
-                            .split(&this_pane, &new_pane, split_direction)?;
+                            .split(&this_pane, &new_pane, split_direction, cx)?;
                         anyhow::Ok(new_pane)
                     })
                 })
@@ -1462,7 +1462,7 @@ impl RunningState {
         this.serialize_layout(window, cx);
         match event {
             Event::Remove { .. } => {
-                let _did_find_pane = this.panes.remove(source_pane).is_ok();
+                let _did_find_pane = this.panes.remove(source_pane, cx).is_ok();
                 debug_assert!(_did_find_pane);
                 cx.notify();
             }
@@ -1889,9 +1889,9 @@ impl RunningState {
         Member::Axis(group_root)
     }
 
-    pub(crate) fn invert_axies(&mut self) {
+    pub(crate) fn invert_axies(&mut self, cx: &mut App) {
         self.dock_axis = self.dock_axis.invert();
-        self.panes.invert_axies();
+        self.panes.invert_axies(cx);
     }
 }
 

crates/editor/src/split.rs 🔗

@@ -194,7 +194,7 @@ impl SplittableEditor {
         });
         let primary_pane = self.panes.first_pane();
         self.panes
-            .split(&primary_pane, &secondary_pane, SplitDirection::Left)
+            .split(&primary_pane, &secondary_pane, SplitDirection::Left, cx)
             .unwrap();
         cx.notify();
     }
@@ -203,7 +203,7 @@ impl SplittableEditor {
         let Some(secondary) = self.secondary.take() else {
             return;
         };
-        self.panes.remove(&secondary.pane).unwrap();
+        self.panes.remove(&secondary.pane, cx).unwrap();
         self.primary_editor.update(cx, |primary, cx| {
             primary.buffer().update(cx, |buffer, _| {
                 buffer.set_filter_mode(None);

crates/feature_flags/src/flags.rs 🔗

@@ -17,3 +17,9 @@ pub struct InlineAssistantUseToolFeatureFlag;
 impl FeatureFlag for InlineAssistantUseToolFeatureFlag {
     const NAME: &'static str = "inline-assistant-use-tool";
 }
+
+pub struct AgentV2FeatureFlag;
+
+impl FeatureFlag for AgentV2FeatureFlag {
+    const NAME: &'static str = "agent-v2";
+}

crates/settings/src/settings_content/agent.rs 🔗

@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
 use settings_macros::{MergeFrom, with_fallible_options};
 use std::{borrow::Cow, path::PathBuf, sync::Arc};
 
-use crate::DockPosition;
+use crate::{DockPosition, DockSide};
 
 #[with_fallible_options]
 #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
@@ -22,6 +22,10 @@ pub struct AgentSettingsContent {
     ///
     /// Default: right
     pub dock: Option<DockPosition>,
+    /// Where to dock the utility pane (the thread view pane).
+    ///
+    /// Default: left
+    pub agents_panel_dock: Option<DockSide>,
     /// Default width in pixels when the agent panel is docked to the left or right.
     ///
     /// Default: 640

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -342,7 +342,7 @@ impl TerminalPanel {
             pane::Event::RemovedItem { .. } => self.serialize(cx),
             pane::Event::Remove { focus_on_pane } => {
                 let pane_count_before_removal = self.center.panes().len();
-                let _removal_result = self.center.remove(pane);
+                let _removal_result = self.center.remove(pane, cx);
                 if pane_count_before_removal == 1 {
                     self.center.first_pane().update(cx, |pane, cx| {
                         pane.set_zoomed(false, cx);
@@ -393,7 +393,10 @@ impl TerminalPanel {
                         };
                         panel
                             .update_in(cx, |panel, window, cx| {
-                                panel.center.split(&pane, &new_pane, direction).log_err();
+                                panel
+                                    .center
+                                    .split(&pane, &new_pane, direction, cx)
+                                    .log_err();
                                 window.focus(&new_pane.focus_handle(cx));
                             })
                             .ok();
@@ -415,7 +418,7 @@ impl TerminalPanel {
                     new_pane.update(cx, |pane, cx| {
                         pane.add_item(item, true, true, None, window, cx);
                     });
-                    self.center.split(&pane, &new_pane, direction).log_err();
+                    self.center.split(&pane, &new_pane, direction, cx).log_err();
                     window.focus(&new_pane.focus_handle(cx));
                 }
             }
@@ -1066,7 +1069,7 @@ impl TerminalPanel {
             .find_pane_in_direction(&self.active_pane, direction, cx)
             .cloned()
         {
-            self.center.swap(&self.active_pane, &to);
+            self.center.swap(&self.active_pane, &to, cx);
             cx.notify();
         }
     }
@@ -1074,7 +1077,7 @@ impl TerminalPanel {
     fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
         if self
             .center
-            .move_to_border(&self.active_pane, direction)
+            .move_to_border(&self.active_pane, direction, cx)
             .unwrap()
         {
             cx.notify();
@@ -1189,6 +1192,7 @@ pub fn new_terminal_pane(
                                         &this_pane,
                                         &new_pane,
                                         split_direction,
+                                        cx,
                                     )?;
                                     anyhow::Ok(new_pane)
                                 })
@@ -1482,6 +1486,7 @@ impl Render for TerminalPanel {
                                                     &terminal_panel.active_pane,
                                                     &new_pane,
                                                     SplitDirection::Right,
+                                                    cx,
                                                 )
                                                 .log_err();
                                             let new_pane = new_pane.read(cx);

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

@@ -10,6 +10,7 @@ pub struct TabBar {
     start_children: SmallVec<[AnyElement; 2]>,
     children: SmallVec<[AnyElement; 2]>,
     end_children: SmallVec<[AnyElement; 2]>,
+    pre_end_children: SmallVec<[AnyElement; 2]>,
     scroll_handle: Option<ScrollHandle>,
 }
 
@@ -20,6 +21,7 @@ impl TabBar {
             start_children: SmallVec::new(),
             children: SmallVec::new(),
             end_children: SmallVec::new(),
+            pre_end_children: SmallVec::new(),
             scroll_handle: None,
         }
     }
@@ -70,6 +72,15 @@ impl TabBar {
         self
     }
 
+    pub fn pre_end_child(mut self, end_child: impl IntoElement) -> Self
+    where
+        Self: Sized,
+    {
+        self.pre_end_children
+            .push(end_child.into_element().into_any());
+        self
+    }
+
     pub fn end_children(mut self, end_children: impl IntoIterator<Item = impl IntoElement>) -> Self
     where
         Self: Sized,
@@ -137,18 +148,31 @@ impl RenderOnce for TabBar {
                             .children(self.children),
                     ),
             )
-            .when(!self.end_children.is_empty(), |this| {
-                this.child(
-                    h_flex()
-                        .flex_none()
-                        .gap(DynamicSpacing::Base04.rems(cx))
-                        .px(DynamicSpacing::Base06.rems(cx))
-                        .border_b_1()
-                        .border_l_1()
-                        .border_color(cx.theme().colors().border)
-                        .children(self.end_children),
-                )
-            })
+            .when(
+                !self.end_children.is_empty() || !self.pre_end_children.is_empty(),
+                |this| {
+                    this.child(
+                        h_flex()
+                            .flex_none()
+                            .gap(DynamicSpacing::Base04.rems(cx))
+                            .px(DynamicSpacing::Base06.rems(cx))
+                            .children(self.pre_end_children)
+                            .border_color(cx.theme().colors().border)
+                            .border_b_1()
+                            .when(!self.end_children.is_empty(), |div| {
+                                div.child(
+                                    h_flex()
+                                        .flex_none()
+                                        .pl(DynamicSpacing::Base04.rems(cx))
+                                        .gap(DynamicSpacing::Base04.rems(cx))
+                                        .border_l_1()
+                                        .border_color(cx.theme().colors().border)
+                                        .children(self.end_children),
+                                )
+                            }),
+                    )
+                },
+            )
     }
 }
 

crates/workspace/Cargo.toml 🔗

@@ -35,6 +35,7 @@ clock.workspace = true
 collections.workspace = true
 component.workspace = true
 db.workspace = true
+feature_flags.workspace = true
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true

crates/workspace/src/dock.rs 🔗

@@ -1,8 +1,10 @@
 use crate::persistence::model::DockData;
+use crate::utility_pane::utility_slot_for_dock_position;
 use crate::{DraggedDock, Event, ModalLayer, Pane};
 use crate::{Workspace, status_bar::StatusItemView};
 use anyhow::Context as _;
 use client::proto;
+
 use gpui::{
     Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle,
     Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement,
@@ -13,6 +15,7 @@ use settings::SettingsStore;
 use std::sync::Arc;
 use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex};
 use ui::{prelude::*, right_click_menu};
+use util::ResultExt as _;
 
 pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.);
 
@@ -25,6 +28,72 @@ pub enum PanelEvent {
 
 pub use proto::PanelId;
 
+pub struct MinimizePane;
+pub struct ClosePane;
+
+pub trait UtilityPane: EventEmitter<MinimizePane> + EventEmitter<ClosePane> + Render {
+    fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition;
+    /// The icon to render in the adjacent pane's tab bar for toggling this utility pane
+    fn toggle_icon(&self, cx: &App) -> IconName;
+    fn expanded(&self, cx: &App) -> bool;
+    fn set_expanded(&mut self, expanded: bool, cx: &mut Context<Self>);
+    fn width(&self, cx: &App) -> Pixels;
+    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
+}
+
+pub trait UtilityPaneHandle: 'static + Send + Sync {
+    fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition;
+    fn toggle_icon(&self, cx: &App) -> IconName;
+    fn expanded(&self, cx: &App) -> bool;
+    fn set_expanded(&self, expanded: bool, cx: &mut App);
+    fn width(&self, cx: &App) -> Pixels;
+    fn set_width(&self, width: Option<Pixels>, cx: &mut App);
+    fn to_any(&self) -> AnyView;
+    fn box_clone(&self) -> Box<dyn UtilityPaneHandle>;
+}
+
+impl<T> UtilityPaneHandle for Entity<T>
+where
+    T: UtilityPane,
+{
+    fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition {
+        self.read(cx).position(window, cx)
+    }
+
+    fn toggle_icon(&self, cx: &App) -> IconName {
+        self.read(cx).toggle_icon(cx)
+    }
+
+    fn expanded(&self, cx: &App) -> bool {
+        self.read(cx).expanded(cx)
+    }
+
+    fn set_expanded(&self, expanded: bool, cx: &mut App) {
+        self.update(cx, |this, cx| this.set_expanded(expanded, cx))
+    }
+
+    fn width(&self, cx: &App) -> Pixels {
+        self.read(cx).width(cx)
+    }
+
+    fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
+        self.update(cx, |this, cx| this.set_width(width, cx))
+    }
+
+    fn to_any(&self) -> AnyView {
+        self.clone().into()
+    }
+
+    fn box_clone(&self) -> Box<dyn UtilityPaneHandle> {
+        Box::new(self.clone())
+    }
+}
+
+pub enum UtilityPanePosition {
+    Left,
+    Right,
+}
+
 pub trait Panel: Focusable + EventEmitter<PanelEvent> + Render + Sized {
     fn persistent_name() -> &'static str;
     fn panel_key() -> &'static str;
@@ -384,6 +453,13 @@ impl Dock {
             .position(|entry| entry.panel.remote_id() == Some(panel_id))
     }
 
+    pub fn panel_for_id(&self, panel_id: EntityId) -> Option<&Arc<dyn PanelHandle>> {
+        self.panel_entries
+            .iter()
+            .find(|entry| entry.panel.panel_id() == panel_id)
+            .map(|entry| &entry.panel)
+    }
+
     pub fn first_enabled_panel_idx(&mut self, cx: &mut Context<Self>) -> anyhow::Result<usize> {
         self.panel_entries
             .iter()
@@ -491,6 +567,9 @@ impl Dock {
 
                     new_dock.update(cx, |new_dock, cx| {
                         new_dock.remove_panel(&panel, window, cx);
+                    });
+
+                    new_dock.update(cx, |new_dock, cx| {
                         let index =
                             new_dock.add_panel(panel.clone(), workspace.clone(), window, cx);
                         if was_visible {
@@ -498,6 +577,12 @@ impl Dock {
                             new_dock.activate_panel(index, window, cx);
                         }
                     });
+
+                    workspace
+                        .update(cx, |workspace, cx| {
+                            workspace.serialize_workspace(window, cx);
+                        })
+                        .ok();
                 }
             }),
             cx.subscribe_in(
@@ -586,6 +671,7 @@ impl Dock {
         );
 
         self.restore_state(window, cx);
+
         if panel.read(cx).starts_open(window, cx) {
             self.activate_panel(index, window, cx);
             self.set_open(true, window, cx);
@@ -637,6 +723,14 @@ impl Dock {
                     std::cmp::Ordering::Greater => {}
                 }
             }
+
+            let slot = utility_slot_for_dock_position(self.position);
+            if let Some(workspace) = self.workspace.upgrade() {
+                workspace.update(cx, |workspace, cx| {
+                    workspace.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
+                });
+            }
+
             self.panel_entries.remove(panel_ix);
             cx.notify();
         }
@@ -891,7 +985,13 @@ impl Render for PanelButtons {
             .enumerate()
             .filter_map(|(i, entry)| {
                 let icon = entry.panel.icon(window, cx)?;
-                let icon_tooltip = entry.panel.icon_tooltip(window, cx)?;
+                let icon_tooltip = entry
+                    .panel
+                    .icon_tooltip(window, cx)
+                    .ok_or_else(|| {
+                        anyhow::anyhow!("can't render a panel button without an icon tooltip")
+                    })
+                    .log_err()?;
                 let name = entry.panel.persistent_name();
                 let panel = entry.panel.clone();
 

crates/workspace/src/pane.rs 🔗

@@ -11,10 +11,12 @@ use crate::{
     move_item,
     notifications::NotifyResultExt,
     toolbar::Toolbar,
+    utility_pane::UtilityPaneSlot,
     workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings},
 };
 use anyhow::Result;
 use collections::{BTreeSet, HashMap, HashSet, VecDeque};
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use futures::{StreamExt, stream::FuturesUnordered};
 use gpui::{
     Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div,
@@ -396,6 +398,10 @@ pub struct Pane {
     diagnostic_summary_update: Task<()>,
     /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here.
     pub project_item_restoration_data: HashMap<ProjectItemKind, Box<dyn Any + Send>>,
+
+    pub in_center_group: bool,
+    pub is_upper_left: bool,
+    pub is_upper_right: bool,
 }
 
 pub struct ActivationHistoryEntry {
@@ -540,6 +546,9 @@ impl Pane {
             zoom_out_on_close: true,
             diagnostic_summary_update: Task::ready(()),
             project_item_restoration_data: HashMap::default(),
+            in_center_group: false,
+            is_upper_left: false,
+            is_upper_right: false,
         }
     }
 
@@ -3033,6 +3042,10 @@ impl Pane {
     }
 
     fn render_tab_bar(&mut self, window: &mut Window, cx: &mut Context<Pane>) -> AnyElement {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return gpui::Empty.into_any();
+        };
+
         let focus_handle = self.focus_handle.clone();
         let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft)
             .icon_size(IconSize::Small)
@@ -3057,6 +3070,44 @@ impl Pane {
                 }
             });
 
+        let open_aside_left = {
+            let workspace = workspace.read(cx);
+            workspace.utility_pane(UtilityPaneSlot::Left).map(|pane| {
+                let toggle_icon = pane.toggle_icon(cx);
+                let workspace_handle = self.workspace.clone();
+
+                IconButton::new("open_aside_left", toggle_icon)
+                    .icon_size(IconSize::Small)
+                    .on_click(move |_, window, cx| {
+                        workspace_handle
+                            .update(cx, |workspace, cx| {
+                                workspace.toggle_utility_pane(UtilityPaneSlot::Left, window, cx)
+                            })
+                            .ok();
+                    })
+                    .into_any_element()
+            })
+        };
+
+        let open_aside_right = {
+            let workspace = workspace.read(cx);
+            workspace.utility_pane(UtilityPaneSlot::Right).map(|pane| {
+                let toggle_icon = pane.toggle_icon(cx);
+                let workspace_handle = self.workspace.clone();
+
+                IconButton::new("open_aside_right", toggle_icon)
+                    .icon_size(IconSize::Small)
+                    .on_click(move |_, window, cx| {
+                        workspace_handle
+                            .update(cx, |workspace, cx| {
+                                workspace.toggle_utility_pane(UtilityPaneSlot::Right, window, cx)
+                            })
+                            .ok();
+                    })
+                    .into_any_element()
+            })
+        };
+
         let navigate_forward = IconButton::new("navigate_forward", IconName::ArrowRight)
             .icon_size(IconSize::Small)
             .on_click({
@@ -3103,13 +3154,50 @@ impl Pane {
         let unpinned_tabs = tab_items.split_off(self.pinned_tab_count);
         let pinned_tabs = tab_items;
 
+        let render_aside_toggle_left = cx.has_flag::<AgentV2FeatureFlag>()
+            && self
+                .is_upper_left
+                .then(|| {
+                    self.workspace.upgrade().and_then(|entity| {
+                        let workspace = entity.read(cx);
+                        workspace
+                            .utility_pane(UtilityPaneSlot::Left)
+                            .map(|pane| !pane.expanded(cx))
+                    })
+                })
+                .flatten()
+                .unwrap_or(false);
+
+        let render_aside_toggle_right = cx.has_flag::<AgentV2FeatureFlag>()
+            && self
+                .is_upper_right
+                .then(|| {
+                    self.workspace.upgrade().and_then(|entity| {
+                        let workspace = entity.read(cx);
+                        workspace
+                            .utility_pane(UtilityPaneSlot::Right)
+                            .map(|pane| !pane.expanded(cx))
+                    })
+                })
+                .flatten()
+                .unwrap_or(false);
+
         TabBar::new("tab_bar")
+            .map(|tab_bar| {
+                if let Some(open_aside_left) = open_aside_left
+                    && render_aside_toggle_left
+                {
+                    tab_bar.start_child(open_aside_left)
+                } else {
+                    tab_bar
+                }
+            })
             .when(
                 self.display_nav_history_buttons.unwrap_or_default(),
                 |tab_bar| {
                     tab_bar
-                        .start_child(navigate_backward)
-                        .start_child(navigate_forward)
+                        .pre_end_child(navigate_backward)
+                        .pre_end_child(navigate_forward)
                 },
             )
             .map(|tab_bar| {
@@ -3196,6 +3284,15 @@ impl Pane {
                             })),
                     ),
             )
+            .map(|tab_bar| {
+                if let Some(open_aside_right) = open_aside_right
+                    && render_aside_toggle_right
+                {
+                    tab_bar.end_child(open_aside_right)
+                } else {
+                    tab_bar
+                }
+            })
             .into_any_element()
     }
 
@@ -6664,8 +6761,8 @@ mod tests {
         let scroll_bounds = tab_bar_scroll_handle.bounds();
         let scroll_offset = tab_bar_scroll_handle.offset();
         assert!(tab_bounds.right() <= scroll_bounds.right() + scroll_offset.x);
-        // -39.5 is the magic number for this setup
-        assert_eq!(scroll_offset.x, px(-39.5));
+        // -35.0 is the magic number for this setup
+        assert_eq!(scroll_offset.x, px(-35.0));
         assert!(
             !tab_bounds.intersects(&new_tab_button_bounds),
             "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!"

crates/workspace/src/pane_group.rs 🔗

@@ -28,6 +28,7 @@ const VERTICAL_MIN_SIZE: f32 = 100.;
 #[derive(Clone)]
 pub struct PaneGroup {
     pub root: Member,
+    pub is_center: bool,
 }
 
 pub struct PaneRenderResult {
@@ -37,22 +38,31 @@ pub struct PaneRenderResult {
 
 impl PaneGroup {
     pub fn with_root(root: Member) -> Self {
-        Self { root }
+        Self {
+            root,
+            is_center: false,
+        }
     }
 
     pub fn new(pane: Entity<Pane>) -> Self {
         Self {
             root: Member::Pane(pane),
+            is_center: false,
         }
     }
 
+    pub fn set_is_center(&mut self, is_center: bool) {
+        self.is_center = is_center;
+    }
+
     pub fn split(
         &mut self,
         old_pane: &Entity<Pane>,
         new_pane: &Entity<Pane>,
         direction: SplitDirection,
+        cx: &mut App,
     ) -> Result<()> {
-        match &mut self.root {
+        let result = match &mut self.root {
             Member::Pane(pane) => {
                 if pane == old_pane {
                     self.root = Member::new_axis(old_pane.clone(), new_pane.clone(), direction);
@@ -62,7 +72,11 @@ impl PaneGroup {
                 }
             }
             Member::Axis(axis) => axis.split(old_pane, new_pane, direction),
+        };
+        if result.is_ok() {
+            self.mark_positions(cx);
         }
+        result
     }
 
     pub fn bounding_box_for_pane(&self, pane: &Entity<Pane>) -> Option<Bounds<Pixels>> {
@@ -90,6 +104,7 @@ impl PaneGroup {
         &mut self,
         active_pane: &Entity<Pane>,
         direction: SplitDirection,
+        cx: &mut App,
     ) -> Result<bool> {
         if let Some(pane) = self.find_pane_at_border(direction)
             && pane == active_pane
@@ -97,7 +112,7 @@ impl PaneGroup {
             return Ok(false);
         }
 
-        if !self.remove(active_pane)? {
+        if !self.remove_internal(active_pane)? {
             return Ok(false);
         }
 
@@ -110,6 +125,7 @@ impl PaneGroup {
                 0
             };
             root.insert_pane(idx, active_pane);
+            self.mark_positions(cx);
             return Ok(true);
         }
 
@@ -119,6 +135,7 @@ impl PaneGroup {
             vec![Member::Pane(active_pane.clone()), self.root.clone()]
         };
         self.root = Member::Axis(PaneAxis::new(direction.axis(), members));
+        self.mark_positions(cx);
         Ok(true)
     }
 
@@ -133,7 +150,15 @@ impl PaneGroup {
     /// - Ok(true) if it found and removed a pane
     /// - Ok(false) if it found but did not remove the pane
     /// - Err(_) if it did not find the pane
-    pub fn remove(&mut self, pane: &Entity<Pane>) -> Result<bool> {
+    pub fn remove(&mut self, pane: &Entity<Pane>, cx: &mut App) -> Result<bool> {
+        let result = self.remove_internal(pane);
+        if let Ok(true) = result {
+            self.mark_positions(cx);
+        }
+        result
+    }
+
+    fn remove_internal(&mut self, pane: &Entity<Pane>) -> Result<bool> {
         match &mut self.root {
             Member::Pane(_) => Ok(false),
             Member::Axis(axis) => {
@@ -151,6 +176,7 @@ impl PaneGroup {
         direction: Axis,
         amount: Pixels,
         bounds: &Bounds<Pixels>,
+        cx: &mut App,
     ) {
         match &mut self.root {
             Member::Pane(_) => {}
@@ -158,22 +184,29 @@ impl PaneGroup {
                 let _ = axis.resize(pane, direction, amount, bounds);
             }
         };
+        self.mark_positions(cx);
     }
 
-    pub fn reset_pane_sizes(&mut self) {
+    pub fn reset_pane_sizes(&mut self, cx: &mut App) {
         match &mut self.root {
             Member::Pane(_) => {}
             Member::Axis(axis) => {
                 let _ = axis.reset_pane_sizes();
             }
         };
+        self.mark_positions(cx);
     }
 
-    pub fn swap(&mut self, from: &Entity<Pane>, to: &Entity<Pane>) {
+    pub fn swap(&mut self, from: &Entity<Pane>, to: &Entity<Pane>, cx: &mut App) {
         match &mut self.root {
             Member::Pane(_) => {}
             Member::Axis(axis) => axis.swap(from, to),
         };
+        self.mark_positions(cx);
+    }
+
+    pub fn mark_positions(&mut self, cx: &mut App) {
+        self.root.mark_positions(self.is_center, true, true, cx);
     }
 
     pub fn render(
@@ -232,8 +265,9 @@ impl PaneGroup {
         self.pane_at_pixel_position(target)
     }
 
-    pub fn invert_axies(&mut self) {
+    pub fn invert_axies(&mut self, cx: &mut App) {
         self.root.invert_pane_axies();
+        self.mark_positions(cx);
     }
 }
 
@@ -243,6 +277,43 @@ pub enum Member {
     Pane(Entity<Pane>),
 }
 
+impl Member {
+    pub fn mark_positions(
+        &mut self,
+        in_center_group: bool,
+        is_upper_left: bool,
+        is_upper_right: bool,
+        cx: &mut App,
+    ) {
+        match self {
+            Member::Axis(pane_axis) => {
+                let len = pane_axis.members.len();
+                for (idx, member) in pane_axis.members.iter_mut().enumerate() {
+                    let member_upper_left = match pane_axis.axis {
+                        Axis::Vertical => is_upper_left && idx == 0,
+                        Axis::Horizontal => is_upper_left && idx == 0,
+                    };
+                    let member_upper_right = match pane_axis.axis {
+                        Axis::Vertical => is_upper_right && idx == 0,
+                        Axis::Horizontal => is_upper_right && idx == len - 1,
+                    };
+                    member.mark_positions(
+                        in_center_group,
+                        member_upper_left,
+                        member_upper_right,
+                        cx,
+                    );
+                }
+            }
+            Member::Pane(entity) => entity.update(cx, |pane, _| {
+                pane.in_center_group = in_center_group;
+                pane.is_upper_left = is_upper_left;
+                pane.is_upper_right = is_upper_right;
+            }),
+        }
+    }
+}
+
 #[derive(Clone, Copy)]
 pub struct PaneRenderContext<'a> {
     pub project: &'a Entity<Project>,

crates/workspace/src/utility_pane.rs 🔗

@@ -0,0 +1,282 @@
+use gpui::{
+    AppContext as _, EntityId, MouseButton, Pixels, Render, StatefulInteractiveElement,
+    Subscription, WeakEntity, deferred, px,
+};
+use ui::{
+    ActiveTheme as _, Context, FluentBuilder as _, InteractiveElement as _, IntoElement,
+    ParentElement as _, RenderOnce, Styled as _, Window, div,
+};
+
+use crate::{
+    DockPosition, Workspace,
+    dock::{ClosePane, MinimizePane, UtilityPane, UtilityPaneHandle},
+};
+
+pub(crate) const UTILITY_PANE_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+pub(crate) const UTILITY_PANE_MIN_WIDTH: Pixels = px(20.0);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum UtilityPaneSlot {
+    Left,
+    Right,
+}
+
+struct UtilityPaneSlotState {
+    panel_id: EntityId,
+    utility_pane: Box<dyn UtilityPaneHandle>,
+    _subscriptions: Vec<Subscription>,
+}
+
+#[derive(Default)]
+pub struct UtilityPaneState {
+    left_slot: Option<UtilityPaneSlotState>,
+    right_slot: Option<UtilityPaneSlotState>,
+}
+
+#[derive(Clone)]
+pub struct DraggedUtilityPane(pub UtilityPaneSlot);
+
+impl Render for DraggedUtilityPane {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        gpui::Empty
+    }
+}
+
+pub fn utility_slot_for_dock_position(position: DockPosition) -> UtilityPaneSlot {
+    match position {
+        DockPosition::Left => UtilityPaneSlot::Left,
+        DockPosition::Right => UtilityPaneSlot::Right,
+        DockPosition::Bottom => UtilityPaneSlot::Left,
+    }
+}
+
+impl Workspace {
+    pub fn utility_pane(&self, slot: UtilityPaneSlot) -> Option<&dyn UtilityPaneHandle> {
+        match slot {
+            UtilityPaneSlot::Left => self
+                .utility_panes
+                .left_slot
+                .as_ref()
+                .map(|s| s.utility_pane.as_ref()),
+            UtilityPaneSlot::Right => self
+                .utility_panes
+                .right_slot
+                .as_ref()
+                .map(|s| s.utility_pane.as_ref()),
+        }
+    }
+
+    pub fn toggle_utility_pane(
+        &mut self,
+        slot: UtilityPaneSlot,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(handle) = self.utility_pane(slot) {
+            let current = handle.expanded(cx);
+            handle.set_expanded(!current, cx);
+        }
+        cx.notify();
+        self.serialize_workspace(window, cx);
+    }
+
+    pub fn register_utility_pane<T: UtilityPane>(
+        &mut self,
+        slot: UtilityPaneSlot,
+        panel_id: EntityId,
+        handle: gpui::Entity<T>,
+        cx: &mut Context<Self>,
+    ) {
+        let minimize_subscription =
+            cx.subscribe(&handle, move |this, _, _event: &MinimizePane, cx| {
+                if let Some(handle) = this.utility_pane(slot) {
+                    handle.set_expanded(false, cx);
+                }
+                cx.notify();
+            });
+
+        let close_subscription = cx.subscribe(&handle, move |this, _, _event: &ClosePane, cx| {
+            this.clear_utility_pane(slot, cx);
+        });
+
+        let subscriptions = vec![minimize_subscription, close_subscription];
+        let boxed_handle: Box<dyn UtilityPaneHandle> = Box::new(handle);
+
+        match slot {
+            UtilityPaneSlot::Left => {
+                self.utility_panes.left_slot = Some(UtilityPaneSlotState {
+                    panel_id,
+                    utility_pane: boxed_handle,
+                    _subscriptions: subscriptions,
+                });
+            }
+            UtilityPaneSlot::Right => {
+                self.utility_panes.right_slot = Some(UtilityPaneSlotState {
+                    panel_id,
+                    utility_pane: boxed_handle,
+                    _subscriptions: subscriptions,
+                });
+            }
+        }
+        cx.notify();
+    }
+
+    pub fn clear_utility_pane(&mut self, slot: UtilityPaneSlot, cx: &mut Context<Self>) {
+        match slot {
+            UtilityPaneSlot::Left => {
+                self.utility_panes.left_slot = None;
+            }
+            UtilityPaneSlot::Right => {
+                self.utility_panes.right_slot = None;
+            }
+        }
+        cx.notify();
+    }
+
+    pub fn clear_utility_pane_if_provider(
+        &mut self,
+        slot: UtilityPaneSlot,
+        provider_panel_id: EntityId,
+        cx: &mut Context<Self>,
+    ) {
+        let should_clear = match slot {
+            UtilityPaneSlot::Left => self
+                .utility_panes
+                .left_slot
+                .as_ref()
+                .is_some_and(|slot| slot.panel_id == provider_panel_id),
+            UtilityPaneSlot::Right => self
+                .utility_panes
+                .right_slot
+                .as_ref()
+                .is_some_and(|slot| slot.panel_id == provider_panel_id),
+        };
+
+        if should_clear {
+            self.clear_utility_pane(slot, cx);
+        }
+    }
+
+    pub fn resize_utility_pane(
+        &mut self,
+        slot: UtilityPaneSlot,
+        new_width: Pixels,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(handle) = self.utility_pane(slot) {
+            let max_width = self.max_utility_pane_width(window, cx);
+            let width = new_width.max(UTILITY_PANE_MIN_WIDTH).min(max_width);
+            handle.set_width(Some(width), cx);
+            cx.notify();
+            self.serialize_workspace(window, cx);
+        }
+    }
+
+    pub fn reset_utility_pane_width(
+        &mut self,
+        slot: UtilityPaneSlot,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(handle) = self.utility_pane(slot) {
+            handle.set_width(None, cx);
+            cx.notify();
+            self.serialize_workspace(window, cx);
+        }
+    }
+}
+
+#[derive(IntoElement)]
+pub struct UtilityPaneFrame {
+    workspace: WeakEntity<Workspace>,
+    slot: UtilityPaneSlot,
+    handle: Box<dyn UtilityPaneHandle>,
+}
+
+impl UtilityPaneFrame {
+    pub fn new(
+        slot: UtilityPaneSlot,
+        handle: Box<dyn UtilityPaneHandle>,
+        cx: &mut Context<Workspace>,
+    ) -> Self {
+        let workspace = cx.weak_entity();
+        Self {
+            workspace,
+            slot,
+            handle,
+        }
+    }
+}
+
+impl RenderOnce for UtilityPaneFrame {
+    fn render(self, _window: &mut Window, cx: &mut ui::App) -> impl IntoElement {
+        let workspace = self.workspace.clone();
+        let slot = self.slot;
+        let width = self.handle.width(cx);
+
+        let create_resize_handle = || {
+            let workspace_handle = workspace.clone();
+            let handle = div()
+                .id(match slot {
+                    UtilityPaneSlot::Left => "utility-pane-resize-handle-left",
+                    UtilityPaneSlot::Right => "utility-pane-resize-handle-right",
+                })
+                .on_drag(DraggedUtilityPane(slot), move |pane, _, _, cx| {
+                    cx.stop_propagation();
+                    cx.new(|_| pane.clone())
+                })
+                .on_mouse_down(MouseButton::Left, move |_, _, cx| {
+                    cx.stop_propagation();
+                })
+                .on_mouse_up(
+                    MouseButton::Left,
+                    move |e: &gpui::MouseUpEvent, window, cx| {
+                        if e.click_count == 2 {
+                            workspace_handle
+                                .update(cx, |workspace, cx| {
+                                    workspace.reset_utility_pane_width(slot, window, cx);
+                                })
+                                .ok();
+                            cx.stop_propagation();
+                        }
+                    },
+                )
+                .occlude();
+
+            match slot {
+                UtilityPaneSlot::Left => deferred(
+                    handle
+                        .absolute()
+                        .right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.)
+                        .top(px(0.))
+                        .h_full()
+                        .w(UTILITY_PANE_RESIZE_HANDLE_SIZE)
+                        .cursor_col_resize(),
+                ),
+                UtilityPaneSlot::Right => deferred(
+                    handle
+                        .absolute()
+                        .left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.)
+                        .top(px(0.))
+                        .h_full()
+                        .w(UTILITY_PANE_RESIZE_HANDLE_SIZE)
+                        .cursor_col_resize(),
+                ),
+            }
+        };
+
+        div()
+            .h_full()
+            .bg(cx.theme().colors().tab_bar_background)
+            .w(width)
+            .border_color(cx.theme().colors().border)
+            .when(self.slot == UtilityPaneSlot::Left, |this| this.border_r_1())
+            .when(self.slot == UtilityPaneSlot::Right, |this| {
+                this.border_l_1()
+            })
+            .child(create_resize_handle())
+            .child(self.handle.to_any())
+            .into_any_element()
+    }
+}

crates/workspace/src/workspace.rs 🔗

@@ -15,6 +15,7 @@ pub mod tasks;
 mod theme_preview;
 mod toast_layer;
 mod toolbar;
+pub mod utility_pane;
 mod workspace_settings;
 
 pub use crate::notifications::NotificationFrame;
@@ -30,6 +31,7 @@ use client::{
 };
 use collections::{HashMap, HashSet, hash_map};
 use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE};
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use futures::{
     Future, FutureExt, StreamExt,
     channel::{
@@ -126,11 +128,16 @@ pub use workspace_settings::{
 };
 use zed_actions::{Spawn, feedback::FileBugReport};
 
-use crate::persistence::{
-    SerializedAxis,
-    model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
+use crate::{
+    item::ItemBufferKind, notifications::NotificationId, utility_pane::UTILITY_PANE_MIN_WIDTH,
+};
+use crate::{
+    persistence::{
+        SerializedAxis,
+        model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
+    },
+    utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState},
 };
-use crate::{item::ItemBufferKind, notifications::NotificationId};
 
 pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200);
 
@@ -1175,6 +1182,7 @@ pub struct Workspace {
     scheduled_tasks: Vec<Task<()>>,
     last_open_dock_positions: Vec<DockPosition>,
     removing: bool,
+    utility_panes: UtilityPaneState,
 }
 
 impl EventEmitter<Event> for Workspace {}
@@ -1466,12 +1474,17 @@ impl Workspace {
             this.update_window_title(window, cx);
             this.show_initial_notifications(cx);
         });
+
+        let mut center = PaneGroup::new(center_pane.clone());
+        center.set_is_center(true);
+        center.mark_positions(cx);
+
         Workspace {
             weak_self: weak_handle.clone(),
             zoomed: None,
             zoomed_position: None,
             previous_dock_drag_coordinates: None,
-            center: PaneGroup::new(center_pane.clone()),
+            center,
             panes: vec![center_pane.clone()],
             panes_by_item: Default::default(),
             active_pane: center_pane.clone(),
@@ -1519,6 +1532,7 @@ impl Workspace {
             scheduled_tasks: Vec::new(),
             last_open_dock_positions: Vec::new(),
             removing: false,
+            utility_panes: UtilityPaneState::default(),
         }
     }
 
@@ -3771,7 +3785,7 @@ impl Workspace {
                 let new_pane = self.add_pane(window, cx);
                 if self
                     .center
-                    .split(&split_off_pane, &new_pane, direction)
+                    .split(&split_off_pane, &new_pane, direction, cx)
                     .log_err()
                     .is_none()
                 {
@@ -3956,7 +3970,7 @@ impl Workspace {
                 let new_pane = self.add_pane(window, cx);
                 if self
                     .center
-                    .split(&self.active_pane, &new_pane, action.direction)
+                    .split(&self.active_pane, &new_pane, action.direction, cx)
                     .log_err()
                     .is_none()
                 {
@@ -4010,7 +4024,7 @@ impl Workspace {
 
     pub fn swap_pane_in_direction(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
         if let Some(to) = self.find_pane_in_direction(direction, cx) {
-            self.center.swap(&self.active_pane, &to);
+            self.center.swap(&self.active_pane, &to, cx);
             cx.notify();
         }
     }
@@ -4018,7 +4032,7 @@ impl Workspace {
     pub fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
         if self
             .center
-            .move_to_border(&self.active_pane, direction)
+            .move_to_border(&self.active_pane, direction, cx)
             .unwrap()
         {
             cx.notify();
@@ -4048,13 +4062,13 @@ impl Workspace {
             }
         } else {
             self.center
-                .resize(&self.active_pane, axis, amount, &self.bounds);
+                .resize(&self.active_pane, axis, amount, &self.bounds, cx);
         }
         cx.notify();
     }
 
     pub fn reset_pane_sizes(&mut self, cx: &mut Context<Self>) {
-        self.center.reset_pane_sizes();
+        self.center.reset_pane_sizes(cx);
         cx.notify();
     }
 
@@ -4240,7 +4254,7 @@ impl Workspace {
     ) -> Entity<Pane> {
         let new_pane = self.add_pane(window, cx);
         self.center
-            .split(&pane_to_split, &new_pane, split_direction)
+            .split(&pane_to_split, &new_pane, split_direction, cx)
             .unwrap();
         cx.notify();
         new_pane
@@ -4260,7 +4274,7 @@ impl Workspace {
         new_pane.update(cx, |pane, cx| {
             pane.add_item(item, true, true, None, window, cx)
         });
-        self.center.split(&pane, &new_pane, direction).unwrap();
+        self.center.split(&pane, &new_pane, direction, cx).unwrap();
         cx.notify();
     }
 
@@ -4285,7 +4299,7 @@ impl Workspace {
                     new_pane.update(cx, |pane, cx| {
                         pane.add_item(clone, true, true, None, window, cx)
                     });
-                    this.center.split(&pane, &new_pane, direction).unwrap();
+                    this.center.split(&pane, &new_pane, direction, cx).unwrap();
                     cx.notify();
                     new_pane
                 })
@@ -4332,7 +4346,7 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.center.remove(&pane).unwrap() {
+        if self.center.remove(&pane, cx).unwrap() {
             self.force_remove_pane(&pane, &focus_on, window, cx);
             self.unfollow_in_pane(&pane, window, cx);
             self.last_leaders_by_pane.remove(&pane.downgrade());
@@ -5684,6 +5698,9 @@ impl Workspace {
 
                     // Swap workspace center group
                     workspace.center = PaneGroup::with_root(center_group);
+                    workspace.center.set_is_center(true);
+                    workspace.center.mark_positions(cx);
+
                     if let Some(active_pane) = active_pane {
                         workspace.set_active_pane(&active_pane, window, cx);
                         cx.focus_self(window);
@@ -6309,6 +6326,7 @@ impl Workspace {
                 left_dock.resize_active_panel(Some(size), window, cx);
             }
         });
+        self.clamp_utility_pane_widths(window, cx);
     }
 
     fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) {
@@ -6331,6 +6349,7 @@ impl Workspace {
                 right_dock.resize_active_panel(Some(size), window, cx);
             }
         });
+        self.clamp_utility_pane_widths(window, cx);
     }
 
     fn resize_bottom_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) {
@@ -6345,6 +6364,42 @@ impl Workspace {
                 bottom_dock.resize_active_panel(Some(size), window, cx);
             }
         });
+        self.clamp_utility_pane_widths(window, cx);
+    }
+
+    fn max_utility_pane_width(&self, window: &Window, cx: &App) -> Pixels {
+        let left_dock_width = self
+            .left_dock
+            .read(cx)
+            .active_panel_size(window, cx)
+            .unwrap_or(px(0.0));
+        let right_dock_width = self
+            .right_dock
+            .read(cx)
+            .active_panel_size(window, cx)
+            .unwrap_or(px(0.0));
+        let center_pane_width = self.bounds.size.width - left_dock_width - right_dock_width;
+        center_pane_width - px(10.0)
+    }
+
+    fn clamp_utility_pane_widths(&mut self, window: &mut Window, cx: &mut App) {
+        let max_width = self.max_utility_pane_width(window, cx);
+
+        // Clamp left slot utility pane if it exists
+        if let Some(handle) = self.utility_pane(UtilityPaneSlot::Left) {
+            let current_width = handle.width(cx);
+            if current_width > max_width {
+                handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx);
+            }
+        }
+
+        // Clamp right slot utility pane if it exists
+        if let Some(handle) = self.utility_pane(UtilityPaneSlot::Right) {
+            let current_width = handle.width(cx);
+            if current_width > max_width {
+                handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx);
+            }
+        }
     }
 
     fn toggle_edit_predictions_all_files(
@@ -6812,6 +6867,34 @@ impl Render for Workspace {
                                             }
                                         },
                                     ))
+                                    .on_drag_move(cx.listener(
+                                        move |workspace,
+                                              e: &DragMoveEvent<DraggedUtilityPane>,
+                                              window,
+                                              cx| {
+                                            let slot = e.drag(cx).0;
+                                            match slot {
+                                                UtilityPaneSlot::Left => {
+                                                    let left_dock_width = workspace.left_dock.read(cx)
+                                                        .active_panel_size(window, cx)
+                                                        .unwrap_or(gpui::px(0.0));
+                                                    let new_width = e.event.position.x
+                                                        - workspace.bounds.left()
+                                                        - left_dock_width;
+                                                    workspace.resize_utility_pane(slot, new_width, window, cx);
+                                                }
+                                                UtilityPaneSlot::Right => {
+                                                    let right_dock_width = workspace.right_dock.read(cx)
+                                                        .active_panel_size(window, cx)
+                                                        .unwrap_or(gpui::px(0.0));
+                                                    let new_width = workspace.bounds.right()
+                                                        - e.event.position.x
+                                                        - right_dock_width;
+                                                    workspace.resize_utility_pane(slot, new_width, window, cx);
+                                                }
+                                            }
+                                        },
+                                    ))
                                 })
                                 .child({
                                     match bottom_dock_layout {
@@ -6831,6 +6914,15 @@ impl Render for Workspace {
                                                         window,
                                                         cx,
                                                     ))
+                                                    .when(cx.has_flag::<AgentV2FeatureFlag>(), |this| {
+                                                        this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| {
+                                                            this.when(pane.expanded(cx), |this| {
+                                                                this.child(
+                                                                    UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx)
+                                                                )
+                                                            })
+                                                        })
+                                                    })
                                                     .child(
                                                         div()
                                                             .flex()
@@ -6872,6 +6964,15 @@ impl Render for Workspace {
                                                                     ),
                                                             ),
                                                     )
+                                                    .when(cx.has_flag::<AgentV2FeatureFlag>(), |this| {
+                                                        this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| {
+                                                            this.when(pane.expanded(cx), |this| {
+                                                                this.child(
+                                                                    UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx)
+                                                                )
+                                                            })
+                                                        })
+                                                    })
                                                     .children(self.render_dock(
                                                         DockPosition::Right,
                                                         &self.right_dock,
@@ -6902,6 +7003,15 @@ impl Render for Workspace {
                                                             .flex_row()
                                                             .flex_1()
                                                             .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx))
+                                                            .when(cx.has_flag::<AgentV2FeatureFlag>(), |this| {
+                                                                this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| {
+                                                                    this.when(pane.expanded(cx), |this| {
+                                                                        this.child(
+                                                                            UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx)
+                                                                        )
+                                                                    })
+                                                                })
+                                                            })
                                                             .child(
                                                                 div()
                                                                     .flex()
@@ -6929,6 +7039,13 @@ impl Render for Workspace {
                                                                             .when_some(paddings.1, |this, p| this.child(p.border_l_1())),
                                                                     )
                                                             )
+                                                            .when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| {
+                                                                this.when(pane.expanded(cx), |this| {
+                                                                    this.child(
+                                                                        UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx)
+                                                                    )
+                                                                })
+                                                            })
                                                     )
                                                     .child(
                                                         div()
@@ -6953,6 +7070,15 @@ impl Render for Workspace {
                                                 window,
                                                 cx,
                                             ))
+                                            .when(cx.has_flag::<AgentV2FeatureFlag>(), |this| {
+                                                this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| {
+                                                    this.when(pane.expanded(cx), |this| {
+                                                        this.child(
+                                                            UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx)
+                                                        )
+                                                    })
+                                                })
+                                            })
                                             .child(
                                                 div()
                                                     .flex()
@@ -6991,6 +7117,15 @@ impl Render for Workspace {
                                                                             .when_some(paddings.1, |this, p| this.child(p.border_l_1())),
                                                                     )
                                                             )
+                                                            .when(cx.has_flag::<AgentV2FeatureFlag>(), |this| {
+                                                                this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| {
+                                                                    this.when(pane.expanded(cx), |this| {
+                                                                        this.child(
+                                                                            UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx)
+                                                                        )
+                                                                    })
+                                                                })
+                                                            })
                                                             .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx))
                                                     )
                                                     .child(
@@ -7010,6 +7145,13 @@ impl Render for Workspace {
                                                 window,
                                                 cx,
                                             ))
+                                            .when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| {
+                                                this.when(pane.expanded(cx), |this| {
+                                                    this.child(
+                                                        UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx)
+                                                    )
+                                                })
+                                            })
                                             .child(
                                                 div()
                                                     .flex()
@@ -7047,6 +7189,15 @@ impl Render for Workspace {
                                                         cx,
                                                     )),
                                             )
+                                            .when(cx.has_flag::<AgentV2FeatureFlag>(), |this| {
+                                                this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| {
+                                                    this.when(pane.expanded(cx), |this| {
+                                                        this.child(
+                                                            UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx)
+                                                        )
+                                                    })
+                                                })
+                                            })
                                             .children(self.render_dock(
                                                 DockPosition::Right,
                                                 &self.right_dock,

crates/zed/Cargo.toml 🔗

@@ -26,6 +26,7 @@ acp_tools.workspace = true
 activity_indicator.workspace = true
 agent_settings.workspace = true
 agent_ui.workspace = true
+agent_ui_v2.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 assets.workspace = true

crates/zed/src/main.rs 🔗

@@ -597,6 +597,7 @@ pub fn main() {
             false,
             cx,
         );
+        agent_ui_v2::agents_panel::init(cx);
         repl::init(app_state.fs.clone(), cx);
         recent_projects::init(cx);
 

crates/zed/src/zed.rs 🔗

@@ -10,6 +10,7 @@ mod quick_action_bar;
 pub(crate) mod windows_only_instance;
 
 use agent_ui::{AgentDiffToolbar, AgentPanelDelegate};
+use agent_ui_v2::agents_panel::AgentsPanel;
 use anyhow::Context as _;
 pub use app_menus::*;
 use assets::Assets;
@@ -81,8 +82,9 @@ use vim_mode_setting::VimModeSetting;
 use workspace::notifications::{
     NotificationId, SuppressEvent, dismiss_app_notification, show_app_notification,
 };
+use workspace::utility_pane::utility_slot_for_dock_position;
 use workspace::{
-    AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings,
+    AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings,
     create_and_open_local_file, notifications::simple_message_notification::MessageNotification,
     open_new,
 };
@@ -679,7 +681,8 @@ fn initialize_panels(
             add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()),
             add_panel_when_ready(notification_panel, workspace_handle.clone(), cx.clone()),
             add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()),
-            initialize_agent_panel(workspace_handle, prompt_builder, cx.clone()).map(|r| r.log_err())
+            initialize_agent_panel(workspace_handle.clone(), prompt_builder, cx.clone()).map(|r| r.log_err()),
+            initialize_agents_panel(workspace_handle, cx.clone()).map(|r| r.log_err())
         );
 
         anyhow::Ok(())
@@ -687,58 +690,65 @@ fn initialize_panels(
     .detach();
 }
 
+fn setup_or_teardown_ai_panel<P: Panel>(
+    workspace: &mut Workspace,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+    load_panel: impl FnOnce(
+        WeakEntity<Workspace>,
+        AsyncWindowContext,
+    ) -> Task<anyhow::Result<Entity<P>>>
+    + 'static,
+) -> Task<anyhow::Result<()>> {
+    let disable_ai = SettingsStore::global(cx)
+        .get::<DisableAiSettings>(None)
+        .disable_ai
+        || cfg!(test);
+    let existing_panel = workspace.panel::<P>(cx);
+
+    match (disable_ai, existing_panel) {
+        (false, None) => cx.spawn_in(window, async move |workspace, cx| {
+            let panel = load_panel(workspace.clone(), cx.clone()).await?;
+            workspace.update_in(cx, |workspace, window, cx| {
+                let disable_ai = SettingsStore::global(cx)
+                    .get::<DisableAiSettings>(None)
+                    .disable_ai;
+                let have_panel = workspace.panel::<P>(cx).is_some();
+                if !disable_ai && !have_panel {
+                    workspace.add_panel(panel, window, cx);
+                }
+            })
+        }),
+        (true, Some(existing_panel)) => {
+            workspace.remove_panel::<P>(&existing_panel, window, cx);
+            Task::ready(Ok(()))
+        }
+        _ => Task::ready(Ok(())),
+    }
+}
+
 async fn initialize_agent_panel(
     workspace_handle: WeakEntity<Workspace>,
     prompt_builder: Arc<PromptBuilder>,
     mut cx: AsyncWindowContext,
 ) -> anyhow::Result<()> {
-    fn setup_or_teardown_agent_panel(
-        workspace: &mut Workspace,
-        prompt_builder: Arc<PromptBuilder>,
-        window: &mut Window,
-        cx: &mut Context<Workspace>,
-    ) -> Task<anyhow::Result<()>> {
-        let disable_ai = SettingsStore::global(cx)
-            .get::<DisableAiSettings>(None)
-            .disable_ai
-            || cfg!(test);
-        let existing_panel = workspace.panel::<agent_ui::AgentPanel>(cx);
-        match (disable_ai, existing_panel) {
-            (false, None) => cx.spawn_in(window, async move |workspace, cx| {
-                let panel =
-                    agent_ui::AgentPanel::load(workspace.clone(), prompt_builder, cx.clone())
-                        .await?;
-                workspace.update_in(cx, |workspace, window, cx| {
-                    let disable_ai = SettingsStore::global(cx)
-                        .get::<DisableAiSettings>(None)
-                        .disable_ai;
-                    let have_panel = workspace.panel::<agent_ui::AgentPanel>(cx).is_some();
-                    if !disable_ai && !have_panel {
-                        workspace.add_panel(panel, window, cx);
-                    }
-                })
-            }),
-            (true, Some(existing_panel)) => {
-                workspace.remove_panel::<agent_ui::AgentPanel>(&existing_panel, window, cx);
-                Task::ready(Ok(()))
-            }
-            _ => Task::ready(Ok(())),
-        }
-    }
-
     workspace_handle
         .update_in(&mut cx, |workspace, window, cx| {
-            setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx)
+            let prompt_builder = prompt_builder.clone();
+            setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| {
+                agent_ui::AgentPanel::load(workspace, prompt_builder, cx)
+            })
         })?
         .await?;
 
     workspace_handle.update_in(&mut cx, |workspace, window, cx| {
-        cx.observe_global_in::<SettingsStore>(window, {
+        let prompt_builder = prompt_builder.clone();
+        cx.observe_global_in::<SettingsStore>(window, move |workspace, window, cx| {
             let prompt_builder = prompt_builder.clone();
-            move |workspace, window, cx| {
-                setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx)
-                    .detach_and_log_err(cx);
-            }
+            setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| {
+                agent_ui::AgentPanel::load(workspace, prompt_builder, cx)
+            })
+            .detach_and_log_err(cx);
         })
         .detach();
 
@@ -763,6 +773,31 @@ async fn initialize_agent_panel(
     anyhow::Ok(())
 }
 
+async fn initialize_agents_panel(
+    workspace_handle: WeakEntity<Workspace>,
+    mut cx: AsyncWindowContext,
+) -> anyhow::Result<()> {
+    workspace_handle
+        .update_in(&mut cx, |workspace, window, cx| {
+            setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| {
+                AgentsPanel::load(workspace, cx)
+            })
+        })?
+        .await?;
+
+    workspace_handle.update_in(&mut cx, |_workspace, window, cx| {
+        cx.observe_global_in::<SettingsStore>(window, move |workspace, window, cx| {
+            setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| {
+                AgentsPanel::load(workspace, cx)
+            })
+            .detach_and_log_err(cx);
+        })
+        .detach();
+    })?;
+
+    anyhow::Ok(())
+}
+
 fn register_actions(
     app_state: Arc<AppState>,
     workspace: &mut Workspace,
@@ -1052,6 +1087,18 @@ fn register_actions(
                 workspace.toggle_panel_focus::<TerminalPanel>(window, cx);
             },
         )
+        .register_action(
+            |workspace: &mut Workspace,
+             _: &zed_actions::agent::ToggleAgentPane,
+             window: &mut Window,
+             cx: &mut Context<Workspace>| {
+                if let Some(panel) = workspace.panel::<AgentsPanel>(cx) {
+                    let position = panel.read(cx).position(window, cx);
+                    let slot = utility_slot_for_dock_position(position);
+                    workspace.toggle_utility_pane(slot, window, cx);
+                }
+            },
+        )
         .register_action({
             let app_state = Arc::downgrade(&app_state);
             move |_, _: &NewWindow, _, cx| {
@@ -4714,6 +4761,7 @@ mod tests {
                 "action",
                 "activity_indicator",
                 "agent",
+                "agents",
                 #[cfg(not(target_os = "macos"))]
                 "app_menu",
                 "assistant",
@@ -4941,6 +4989,7 @@ mod tests {
                 false,
                 cx,
             );
+            agent_ui_v2::agents_panel::init(cx);
             repl::init(app_state.fs.clone(), cx);
             repl::notebook::init(cx);
             tasks_ui::init(cx);

crates/zed_actions/src/lib.rs 🔗

@@ -350,6 +350,8 @@ pub mod agent {
             AddSelectionToThread,
             /// Resets the agent panel zoom levels (agent UI and buffer font sizes).
             ResetAgentZoom,
+            /// Toggles the utility/agent pane open/closed state.
+            ToggleAgentPane,
         ]
     );
 }