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}