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