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                true,
125                window,
126                cx,
127            )
128        });
129
130        let notify = cx.observe(&thread_view, |_, _, cx| {
131            cx.notify();
132        });
133
134        self.thread_view = Some(ActiveThreadView {
135            view: thread_view,
136            thread_id,
137            _notify: notify,
138        });
139
140        cx.notify();
141    }
142
143    fn title(&self, cx: &App) -> SharedString {
144        if let Some(active_thread_view) = &self.thread_view {
145            let thread_view = active_thread_view.view.read(cx);
146            if let Some(thread) = thread_view.thread() {
147                let title = thread.read(cx).title();
148                if !title.is_empty() {
149                    return title;
150                }
151            }
152            thread_view.title(cx)
153        } else {
154            "Thread".into()
155        }
156    }
157
158    fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
159        let position = self.position(window, cx);
160        let slot = match position {
161            UtilityPanePosition::Left => UtilityPaneSlot::Left,
162            UtilityPanePosition::Right => UtilityPaneSlot::Right,
163        };
164
165        let workspace = self.workspace.clone();
166        let toggle_icon = self.toggle_icon(cx);
167        let title = self.title(cx);
168
169        let pane_toggle_button = |workspace: WeakEntity<Workspace>| {
170            IconButton::new("toggle_utility_pane", toggle_icon)
171                .icon_size(IconSize::Small)
172                .tooltip(Tooltip::text("Toggle Agent Pane"))
173                .on_click(move |_, window, cx| {
174                    workspace
175                        .update(cx, |workspace, cx| {
176                            workspace.toggle_utility_pane(slot, window, cx)
177                        })
178                        .ok();
179                })
180        };
181
182        h_flex()
183            .id("utility-pane-header")
184            .w_full()
185            .h(Tab::container_height(cx))
186            .px_1p5()
187            .gap(DynamicSpacing::Base06.rems(cx))
188            .when(slot == UtilityPaneSlot::Right, |this| {
189                this.flex_row_reverse()
190            })
191            .flex_none()
192            .border_b_1()
193            .border_color(cx.theme().colors().border)
194            .child(pane_toggle_button(workspace))
195            .child(
196                h_flex()
197                    .size_full()
198                    .min_w_0()
199                    .gap_1()
200                    .map(|this| {
201                        if slot == UtilityPaneSlot::Right {
202                            this.flex_row_reverse().justify_start()
203                        } else {
204                            this.justify_between()
205                        }
206                    })
207                    .child(Label::new(title).truncate())
208                    .child(
209                        IconButton::new("close_btn", IconName::Close)
210                            .icon_size(IconSize::Small)
211                            .tooltip(Tooltip::text("Close Agent Pane"))
212                            .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| {
213                                cx.emit(ClosePane);
214                                this.thread_view = None;
215                                cx.notify()
216                            })),
217                    ),
218            )
219    }
220}
221
222impl Focusable for AgentThreadPane {
223    fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle {
224        if let Some(thread_view) = &self.thread_view {
225            thread_view.view.focus_handle(cx)
226        } else {
227            self.focus_handle.clone()
228        }
229    }
230}
231
232impl UtilityPane for AgentThreadPane {
233    fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition {
234        match AgentSettings::get_global(cx).agents_panel_dock {
235            DockSide::Left => UtilityPanePosition::Left,
236            DockSide::Right => UtilityPanePosition::Right,
237        }
238    }
239
240    fn toggle_icon(&self, _cx: &App) -> IconName {
241        IconName::Thread
242    }
243
244    fn expanded(&self, _cx: &App) -> bool {
245        self.expanded
246    }
247
248    fn set_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
249        self.expanded = expanded;
250        cx.emit(AgentsUtilityPaneEvent::StateChanged);
251        cx.notify();
252    }
253
254    fn width(&self, _cx: &App) -> Pixels {
255        self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH)
256    }
257
258    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
259        self.width = width;
260        cx.emit(AgentsUtilityPaneEvent::StateChanged);
261        cx.notify();
262    }
263}
264
265impl Render for AgentThreadPane {
266    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
267        let content = if let Some(thread_view) = &self.thread_view {
268            div().size_full().child(thread_view.view.clone())
269        } else {
270            div()
271                .size_full()
272                .flex()
273                .items_center()
274                .justify_center()
275                .child(Label::new("Select a thread to view details").size(LabelSize::Default))
276        };
277
278        div()
279            .size_full()
280            .flex()
281            .flex_col()
282            .child(self.render_header(window, cx))
283            .child(content)
284    }
285}