agent_thread_pane.rs

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