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