Detailed changes
@@ -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",
@@ -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" }
@@ -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.
@@ -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()),
@@ -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,
@@ -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
@@ -0,0 +1 @@
+LICENSE-GPL
@@ -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)
+ }
+}
@@ -0,0 +1,4 @@
+mod agent_thread_pane;
+mod thread_history;
+
+pub mod agents_panel;
@@ -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())
+ }
+}
@@ -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);
+ }
+}
@@ -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))
})
});
}
@@ -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);
}
}
@@ -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);
@@ -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";
+}
@@ -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
@@ -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);
@@ -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),
+ )
+ }),
+ )
+ },
+ )
}
}
@@ -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
@@ -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();
@@ -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!"
@@ -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>,
@@ -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()
+ }
+}
@@ -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,
@@ -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
@@ -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);
@@ -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);
@@ -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,
]
);
}