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