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