agent_thread_pane.rs

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