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::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}
 66
 67impl AgentThreadPane {
 68    pub fn new(workspace: WeakEntity<Workspace>, cx: &mut ui::Context<Self>) -> Self {
 69        let focus_handle = cx.focus_handle();
 70        Self {
 71            focus_handle,
 72            expanded: false,
 73            width: None,
 74            thread_view: None,
 75            workspace,
 76        }
 77    }
 78
 79    pub fn thread_id(&self) -> Option<acp::SessionId> {
 80        self.thread_view.as_ref().map(|tv| tv.thread_id.clone())
 81    }
 82
 83    pub fn serialize(&self) -> SerializedAgentThreadPane {
 84        SerializedAgentThreadPane {
 85            expanded: self.expanded,
 86            width: self.width,
 87            thread_id: self.thread_id().map(SerializedHistoryEntryId::from),
 88        }
 89    }
 90
 91    pub fn open_thread(
 92        &mut self,
 93        entry: AgentSessionInfo,
 94        fs: Arc<dyn Fs>,
 95        workspace: WeakEntity<Workspace>,
 96        project: Entity<Project>,
 97        thread_store: Entity<ThreadStore>,
 98        prompt_store: Option<Entity<PromptStore>>,
 99        window: &mut Window,
100        cx: &mut Context<Self>,
101    ) {
102        let thread_id = entry.session_id.clone();
103        let resume_thread = Some(entry);
104
105        let agent: Rc<dyn AgentServer> = Rc::new(NativeAgentServer::new(fs, thread_store.clone()));
106
107        let thread_view = cx.new(|cx| {
108            AcpThreadView::new(
109                agent,
110                resume_thread,
111                None,
112                workspace,
113                project,
114                Some(thread_store),
115                prompt_store,
116                true,
117                window,
118                cx,
119            )
120        });
121
122        let notify = cx.observe(&thread_view, |_, _, cx| {
123            cx.notify();
124        });
125
126        self.thread_view = Some(ActiveThreadView {
127            view: thread_view,
128            thread_id,
129            _notify: notify,
130        });
131
132        cx.notify();
133    }
134
135    fn title(&self, cx: &App) -> SharedString {
136        if let Some(active_thread_view) = &self.thread_view {
137            let thread_view = active_thread_view.view.read(cx);
138            if let Some(thread) = thread_view.thread() {
139                let title = thread.read(cx).title();
140                if !title.is_empty() {
141                    return title;
142                }
143            }
144            thread_view.title(cx)
145        } else {
146            "Thread".into()
147        }
148    }
149
150    fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
151        let position = self.position(window, cx);
152        let slot = match position {
153            UtilityPanePosition::Left => UtilityPaneSlot::Left,
154            UtilityPanePosition::Right => UtilityPaneSlot::Right,
155        };
156
157        let workspace = self.workspace.clone();
158        let toggle_icon = self.toggle_icon(cx);
159        let title = self.title(cx);
160
161        let pane_toggle_button = |workspace: WeakEntity<Workspace>| {
162            IconButton::new("toggle_utility_pane", toggle_icon)
163                .icon_size(IconSize::Small)
164                .tooltip(Tooltip::text("Toggle Agent Pane"))
165                .on_click(move |_, window, cx| {
166                    workspace
167                        .update(cx, |workspace, cx| {
168                            workspace.toggle_utility_pane(slot, window, cx)
169                        })
170                        .ok();
171                })
172        };
173
174        h_flex()
175            .id("utility-pane-header")
176            .w_full()
177            .h(Tab::container_height(cx))
178            .px_1p5()
179            .gap(DynamicSpacing::Base06.rems(cx))
180            .when(slot == UtilityPaneSlot::Right, |this| {
181                this.flex_row_reverse()
182            })
183            .flex_none()
184            .border_b_1()
185            .border_color(cx.theme().colors().border)
186            .child(pane_toggle_button(workspace))
187            .child(
188                h_flex()
189                    .size_full()
190                    .min_w_0()
191                    .gap_1()
192                    .map(|this| {
193                        if slot == UtilityPaneSlot::Right {
194                            this.flex_row_reverse().justify_start()
195                        } else {
196                            this.justify_between()
197                        }
198                    })
199                    .child(Label::new(title).truncate())
200                    .child(
201                        IconButton::new("close_btn", IconName::Close)
202                            .icon_size(IconSize::Small)
203                            .tooltip(Tooltip::text("Close Agent Pane"))
204                            .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| {
205                                cx.emit(ClosePane);
206                                this.thread_view = None;
207                                cx.notify()
208                            })),
209                    ),
210            )
211    }
212}
213
214impl Focusable for AgentThreadPane {
215    fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle {
216        if let Some(thread_view) = &self.thread_view {
217            thread_view.view.focus_handle(cx)
218        } else {
219            self.focus_handle.clone()
220        }
221    }
222}
223
224impl UtilityPane for AgentThreadPane {
225    fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition {
226        match AgentSettings::get_global(cx).agents_panel_dock {
227            DockSide::Left => UtilityPanePosition::Left,
228            DockSide::Right => UtilityPanePosition::Right,
229        }
230    }
231
232    fn toggle_icon(&self, _cx: &App) -> IconName {
233        IconName::Thread
234    }
235
236    fn expanded(&self, _cx: &App) -> bool {
237        self.expanded
238    }
239
240    fn set_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
241        self.expanded = expanded;
242        cx.emit(AgentsUtilityPaneEvent::StateChanged);
243        cx.notify();
244    }
245
246    fn width(&self, _cx: &App) -> Pixels {
247        self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH)
248    }
249
250    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
251        self.width = width;
252        cx.emit(AgentsUtilityPaneEvent::StateChanged);
253        cx.notify();
254    }
255}
256
257impl Render for AgentThreadPane {
258    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
259        let content = if let Some(thread_view) = &self.thread_view {
260            div().size_full().child(thread_view.view.clone())
261        } else {
262            div()
263                .size_full()
264                .flex()
265                .items_center()
266                .justify_center()
267                .child(Label::new("Select a thread to view details").size(LabelSize::Default))
268        };
269
270        div()
271            .size_full()
272            .flex()
273            .flex_col()
274            .child(self.render_header(window, cx))
275            .child(content)
276    }
277}