agent_thread_pane.rs

  1use acp_thread::AgentSessionInfo;
  2use agent::{NativeAgentServer, ThreadStore};
  3use agent_client_protocol as acp;
  4use agent_servers::AgentServer;
  5use agent_settings::AgentSettings;
  6use agent_ui::acp::{AcpThreadHistory, AcpThreadView};
  7use fs::Fs;
  8use gpui::{
  9    Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*,
 10};
 11use project::Project;
 12use prompt_store::PromptStore;
 13use serde::{Deserialize, Serialize};
 14use settings::DockSide;
 15use settings::Settings as _;
 16use std::rc::Rc;
 17use std::sync::Arc;
 18use ui::{Tab, Tooltip, prelude::*};
 19use workspace::{
 20    Workspace,
 21    dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition},
 22    utility_pane::UtilityPaneSlot,
 23};
 24
 25pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0);
 26
 27#[derive(Serialize, Deserialize, Debug, Clone)]
 28pub enum SerializedHistoryEntryId {
 29    AcpThread(String),
 30}
 31
 32impl From<acp::SessionId> for SerializedHistoryEntryId {
 33    fn from(id: acp::SessionId) -> Self {
 34        SerializedHistoryEntryId::AcpThread(id.0.to_string())
 35    }
 36}
 37
 38#[derive(Serialize, Deserialize, Debug)]
 39pub struct SerializedAgentThreadPane {
 40    pub expanded: bool,
 41    pub width: Option<Pixels>,
 42    pub thread_id: Option<SerializedHistoryEntryId>,
 43}
 44
 45pub enum AgentsUtilityPaneEvent {
 46    StateChanged,
 47}
 48
 49impl EventEmitter<AgentsUtilityPaneEvent> for AgentThreadPane {}
 50impl EventEmitter<MinimizePane> for AgentThreadPane {}
 51impl EventEmitter<ClosePane> for AgentThreadPane {}
 52
 53struct ActiveThreadView {
 54    view: Entity<AcpThreadView>,
 55    thread_id: acp::SessionId,
 56    _notify: Subscription,
 57}
 58
 59pub struct AgentThreadPane {
 60    focus_handle: gpui::FocusHandle,
 61    expanded: bool,
 62    width: Option<Pixels>,
 63    thread_view: Option<ActiveThreadView>,
 64    workspace: WeakEntity<Workspace>,
 65    history: Entity<AcpThreadHistory>,
 66}
 67
 68impl AgentThreadPane {
 69    pub fn new(
 70        workspace: WeakEntity<Workspace>,
 71        history: Entity<AcpThreadHistory>,
 72        cx: &mut ui::Context<Self>,
 73    ) -> Self {
 74        let focus_handle = cx.focus_handle();
 75        Self {
 76            focus_handle,
 77            expanded: false,
 78            width: None,
 79            thread_view: None,
 80            workspace,
 81            history,
 82        }
 83    }
 84
 85    pub fn thread_id(&self) -> Option<acp::SessionId> {
 86        self.thread_view.as_ref().map(|tv| tv.thread_id.clone())
 87    }
 88
 89    pub fn serialize(&self) -> SerializedAgentThreadPane {
 90        SerializedAgentThreadPane {
 91            expanded: self.expanded,
 92            width: self.width,
 93            thread_id: self.thread_id().map(SerializedHistoryEntryId::from),
 94        }
 95    }
 96
 97    pub fn open_thread(
 98        &mut self,
 99        entry: AgentSessionInfo,
100        fs: Arc<dyn Fs>,
101        workspace: WeakEntity<Workspace>,
102        project: Entity<Project>,
103        thread_store: Entity<ThreadStore>,
104        prompt_store: Option<Entity<PromptStore>>,
105        window: &mut Window,
106        cx: &mut Context<Self>,
107    ) {
108        let thread_id = entry.session_id.clone();
109        let resume_thread = Some(entry);
110
111        let agent: Rc<dyn AgentServer> = Rc::new(NativeAgentServer::new(fs, thread_store.clone()));
112
113        let history = self.history.clone();
114        let thread_view = cx.new(|cx| {
115            AcpThreadView::new(
116                agent,
117                resume_thread,
118                None,
119                workspace,
120                project,
121                Some(thread_store),
122                prompt_store,
123                history,
124                window,
125                cx,
126            )
127        });
128
129        let notify = cx.observe(&thread_view, |_, _, cx| {
130            cx.notify();
131        });
132
133        self.thread_view = Some(ActiveThreadView {
134            view: thread_view,
135            thread_id,
136            _notify: notify,
137        });
138
139        cx.notify();
140    }
141
142    fn title(&self, cx: &App) -> SharedString {
143        if let Some(active_thread_view) = &self.thread_view {
144            let thread_view = active_thread_view.view.read(cx);
145            if let Some(thread) = thread_view.thread() {
146                let title = thread.read(cx).title();
147                if !title.is_empty() {
148                    return title;
149                }
150            }
151            thread_view.title(cx)
152        } else {
153            "Thread".into()
154        }
155    }
156
157    fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
158        let position = self.position(window, cx);
159        let slot = match position {
160            UtilityPanePosition::Left => UtilityPaneSlot::Left,
161            UtilityPanePosition::Right => UtilityPaneSlot::Right,
162        };
163
164        let workspace = self.workspace.clone();
165        let toggle_icon = self.toggle_icon(cx);
166        let title = self.title(cx);
167
168        let pane_toggle_button = |workspace: WeakEntity<Workspace>| {
169            IconButton::new("toggle_utility_pane", toggle_icon)
170                .icon_size(IconSize::Small)
171                .tooltip(Tooltip::text("Toggle Agent Pane"))
172                .on_click(move |_, window, cx| {
173                    workspace
174                        .update(cx, |workspace, cx| {
175                            workspace.toggle_utility_pane(slot, window, cx)
176                        })
177                        .ok();
178                })
179        };
180
181        h_flex()
182            .id("utility-pane-header")
183            .w_full()
184            .h(Tab::container_height(cx))
185            .px_1p5()
186            .gap(DynamicSpacing::Base06.rems(cx))
187            .when(slot == UtilityPaneSlot::Right, |this| {
188                this.flex_row_reverse()
189            })
190            .flex_none()
191            .border_b_1()
192            .border_color(cx.theme().colors().border)
193            .child(pane_toggle_button(workspace))
194            .child(
195                h_flex()
196                    .size_full()
197                    .min_w_0()
198                    .gap_1()
199                    .map(|this| {
200                        if slot == UtilityPaneSlot::Right {
201                            this.flex_row_reverse().justify_start()
202                        } else {
203                            this.justify_between()
204                        }
205                    })
206                    .child(Label::new(title).truncate())
207                    .child(
208                        IconButton::new("close_btn", IconName::Close)
209                            .icon_size(IconSize::Small)
210                            .tooltip(Tooltip::text("Close Agent Pane"))
211                            .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| {
212                                cx.emit(ClosePane);
213                                this.thread_view = None;
214                                cx.notify()
215                            })),
216                    ),
217            )
218    }
219}
220
221impl Focusable for AgentThreadPane {
222    fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle {
223        if let Some(thread_view) = &self.thread_view {
224            thread_view.view.focus_handle(cx)
225        } else {
226            self.focus_handle.clone()
227        }
228    }
229}
230
231impl UtilityPane for AgentThreadPane {
232    fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition {
233        match AgentSettings::get_global(cx).agents_panel_dock {
234            DockSide::Left => UtilityPanePosition::Left,
235            DockSide::Right => UtilityPanePosition::Right,
236        }
237    }
238
239    fn toggle_icon(&self, _cx: &App) -> IconName {
240        IconName::Thread
241    }
242
243    fn expanded(&self, _cx: &App) -> bool {
244        self.expanded
245    }
246
247    fn set_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
248        self.expanded = expanded;
249        cx.emit(AgentsUtilityPaneEvent::StateChanged);
250        cx.notify();
251    }
252
253    fn width(&self, _cx: &App) -> Pixels {
254        self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH)
255    }
256
257    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
258        self.width = width;
259        cx.emit(AgentsUtilityPaneEvent::StateChanged);
260        cx.notify();
261    }
262}
263
264impl Render for AgentThreadPane {
265    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
266        let content = if let Some(thread_view) = &self.thread_view {
267            div().size_full().child(thread_view.view.clone())
268        } else {
269            div()
270                .size_full()
271                .flex()
272                .items_center()
273                .justify_center()
274                .child(Label::new("Select a thread to view details").size(LabelSize::Default))
275        };
276
277        div()
278            .size_full()
279            .flex()
280            .flex_col()
281            .child(self.render_header(window, cx))
282            .child(content)
283    }
284}